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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c169ca8f3c24e2294f37a17142112c820953fbd3b109c7e53c3a39ccf19e88e4
4
- data.tar.gz: ec8aee4c537d0f1f47b705eb4e248a0b148b124979e743adfd02d1b510b4a9f9
3
+ metadata.gz: f7720d10f3459b8933df3d2521bdce4983e03adf800dc852cb4e6f28d689f146
4
+ data.tar.gz: 746c1c6e7cc864f5555020ab15d21eeac2c601d6f7d6b0bcfa1bff0b9633bb77
5
5
  SHA512:
6
- metadata.gz: 4cb10cc3987eff0d89409c4275cafa26263a9ed349deacb81c816c3fca496927973a640eb1fe875a4e724eebdc43ecc3cec6f5e8626b7f9653e422704bad6553
7
- data.tar.gz: 1f68fa692abff8ab900ac03cc864b5509270c863b62606b8f2ddca4b98c7f05358b61c82663af1f580ab7a5510f690fc3827d2f07c1cc60c574e6146add476be
6
+ metadata.gz: 504fa1a8ea3a50979c1c23e366713e6e6b10bfe38efb4e3a4019b3f5f8cefa35ec460fd8a8b013cd1324c4ead1359fdbc0b00a7c0b56e71bd606c294cf180c6c
7
+ data.tar.gz: 5d1d182cbd5f0538d5d281c1c401b7724fe1bc27e0f3c5009345493a8335aa6bae62ed9dea22af2fc52b07ac8bfc1e617953851cbcaf7c5823773536ef4651ae
data/.env.example CHANGED
@@ -47,14 +47,18 @@ ORGANIZER_URL=http://127.0.0.1:4000/organizer
47
47
  # spree_cm_commissioner/distance.rb
48
48
  DISTANCE_SIGNING_KEY=hei********************VY
49
49
 
50
- # Cache durations (in seconds) for different content types
50
+ # CDN cache durations (s-maxage, in seconds) for listed public APIs; client max-age is always 0, unlisted APIs are never CDN-cached
51
51
  # See: app/controllers/concerns/spree_cm_commissioner/content_cachable.rb
52
52
  CACHE_AUTO_INVALIDATION_DISABLED="" # Set to yes to disable CDN cache invalidation entirely (kill switch)
53
- CACHE_STATIC_MAX_AGE=86400 # Static content (countries, provinces, seat layouts, CMS pages) - 1 day
54
- CACHE_SEMI_STATIC_MAX_AGE=3600 # Semi-static content (menus, homepage backgrounds, routes) - 1 hour
55
- CACHE_MODERATE_MAX_AGE=1800 # Moderate freshness (products, events, vendors) - 30 minutes
56
- CACHE_REALTIME_MAX_AGE=300 # High freshness (trips, trip search) - 5 minutes
57
- CACHE_DEFAULT_MAX_AGE=300 # Default for unlisted controllers - 5 minutes
53
+ CACHE_SERIALIZER_DISABLED="" # Set to yes to turn off Spree's serializer record cache everywhere
54
+ CACHE_STATIC_MAX_AGE=86400 # Static content (CMS pages) - 1 day
55
+ CACHE_REALTIME_MAX_AGE=300 # Everything else listed (homepage, menus, products, events, vendors, trips, seat layouts, countries, provinces, guest card classes) - 5 minutes
56
+
57
+ # Shared secret(s) CloudFront sends to the origin as the X-Origin-Verify custom header. When set, listed CDN-cacheable
58
+ # APIs return 403 to requests without a matching header. Comma-separate two values while rotating. Leave blank to turn off.
59
+ # Set only after every client reaches these APIs through CloudFront, or they get 403.
60
+ CDN_ORIGIN_SECRETS=""
61
+ CDN_ORIGIN_REPORT_ONLY="" # Set to yes to log unverified requests instead of refusing them (use before enforcing)
58
62
 
59
63
  # Batch size for processing maintenance tasks in app/jobs/spree_cm_commissioner/maintenance_tasks/orchestrate_job.rb
60
64
  MAINTENANCE_TASKS_BATCH_SIZE=100
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.3)
37
+ spree_cm_commissioner (2.14.5)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -1,95 +1,134 @@
1
1
  # Check following file for spec:
2
- # spec/controllers/application_controller_spec.rb
2
+ # spec/controllers/concerns/spree_cm_commissioner/content_cachable_spec.rb
3
3
  module SpreeCmCommissioner
4
4
  module ContentCachable
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
+ # Prepended so a request that skipped the CDN is refused before any other callback hits the DB.
9
+ prepend_before_action :verify_cdn_origin!, if: :cdn_cacheable?
8
10
  after_action :set_cache_control_for_cdn
9
11
  end
10
12
 
11
- # Cache duration priorities by content type
12
- # Priority 1: Static/Rarely Changed (1 day)
13
- STATIC_CONTENT_CONTROLLERS = %w[
14
- CountriesController
15
- ProvincesController
16
- GuestCardClassesController
17
- SeatLayoutsController
18
- CmsPagesController
19
- AdvertisementsController
20
- ].freeze
13
+ # CloudFront sends this as an origin custom header; clients never see or send it.
14
+ CDN_ORIGIN_HEADER = 'X-Origin-Verify'.freeze
21
15
 
22
- # Priority 2: Semi-Static (1 hour)
23
- SEMI_STATIC_CONTROLLERS = %w[
24
- MenusController
25
- HomepageBackgroundController
26
- HomepageDataController
27
- PopularRoutePlacesController
28
- RoutePlacesController
29
- ShowsController
30
- EpisodesController
31
- ShowPeopleController
32
- ShowPersonAssignmentsController
33
- ShowContestantsController
34
- VotePackagesController
35
- ShowEliminationSessionsController
36
- PaymentMethodsController
37
- ].freeze
16
+ # Every CDN-cached API, by full class name, with its GET actions and TTL tier. Anything not
17
+ # listed keeps Rails' default `max-age=0, private, must-revalidate` and never reaches the CDN.
18
+ #
19
+ # :static (CACHE_STATIC_MAX_AGE, 1 day) is for rarely changed content. :fresh
20
+ # (CACHE_REALTIME_MAX_AGE, 5 minutes) is for anything editors change, short enough that an edit
21
+ # shows up without a CloudFront invalidation.
22
+ #
23
+ # Full names, not demodulized ones: this concern runs on every ActionController::API, and short
24
+ # names collide with admin-only APIs (Platform, Operator, V1, webhooks) that must never be marked
25
+ # public. Never list an action whose response depends on the signed-in user, e.g.
26
+ # Tenant::VotePackagesController#claim_status: the CDN would serve one user's answer to everyone
27
+ # sharing the cache key.
28
+ CDN_CACHEABLE_ACTIONS = {
29
+ # JSON-LD
30
+ 'Spree::Api::JsonLd::VendorsController' => { actions: %w[index show].freeze, max_age: :fresh },
38
31
 
39
- # Priority 3: Moderate Freshness (30 minutes)
40
- MODERATE_FRESHNESS_CONTROLLERS = %w[
41
- HomepageSectionsController
42
- TaxonsController
43
- ProductsController
44
- EventsController
45
- EventMatchesController
46
- AccommodationsController
47
- VendorsController
48
- VendorPhotosController
49
- ].freeze
32
+ # Storefront
33
+ 'Spree::Api::V2::Storefront::AccommodationsController' => { actions: %w[index show].freeze, max_age: :fresh },
34
+ 'Spree::Api::V2::Storefront::CmsPagesController' => { actions: %w[index show].freeze, max_age: :static },
35
+ 'Spree::Api::V2::Storefront::CountriesController' => { actions: %w[index show].freeze, max_age: :fresh },
36
+ 'Spree::Api::V2::Storefront::DynamicFieldsController' => { actions: %w[index show].freeze, max_age: :fresh },
37
+ 'Spree::Api::V2::Storefront::EventMatchesController' => { actions: %w[index].freeze, max_age: :fresh },
38
+ 'Spree::Api::V2::Storefront::EventsController' => { actions: %w[index show].freeze, max_age: :fresh },
39
+ 'Spree::Api::V2::Storefront::GuestCardClassesController' => { actions: %w[index show].freeze, max_age: :fresh },
40
+ 'Spree::Api::V2::Storefront::HomepageBackgroundController' => { actions: %w[show].freeze, max_age: :fresh },
41
+ 'Spree::Api::V2::Storefront::HomepageDataController' => { actions: %w[show].freeze, max_age: :fresh },
42
+ 'Spree::Api::V2::Storefront::HomepageSectionsController' => { actions: %w[index].freeze, max_age: :fresh },
43
+ 'Spree::Api::V2::Storefront::MenusController' => { actions: %w[index show].freeze, max_age: :fresh },
44
+ 'Spree::Api::V2::Storefront::PaymentMethodsController' => { actions: %w[index].freeze, max_age: :fresh },
45
+ 'Spree::Api::V2::Storefront::PopularRoutePlacesController' => { actions: %w[index].freeze, max_age: :fresh },
46
+ 'Spree::Api::V2::Storefront::ProductsController' => { actions: %w[index show].freeze, max_age: :fresh },
47
+ 'Spree::Api::V2::Storefront::ProvincesController' => { actions: %w[index].freeze, max_age: :fresh },
48
+ 'Spree::Api::V2::Storefront::RoutePlacesController' => { actions: %w[index].freeze, max_age: :fresh },
49
+ 'Spree::Api::V2::Storefront::SeatLayoutsController' => { actions: %w[index show].freeze, max_age: :fresh },
50
+ 'Spree::Api::V2::Storefront::TaxonsController' => { actions: %w[index show].freeze, max_age: :fresh },
51
+ 'Spree::Api::V2::Storefront::TripSearchController' => { actions: %w[index].freeze, max_age: :fresh },
52
+ 'Spree::Api::V2::Storefront::TripsController' => { actions: %w[show].freeze, max_age: :fresh },
53
+ 'Spree::Api::V2::Storefront::VendorPhotosController' => { actions: %w[index show].freeze, max_age: :fresh },
54
+ 'Spree::Api::V2::Storefront::VendorsController' => { actions: %w[index show].freeze, max_age: :fresh },
50
55
 
51
- # Priority 4: High Freshness (5 minutes)
52
- HIGH_FRESHNESS_CONTROLLERS = %w[
53
- TripsController
54
- TripSearchController
55
- VotingContestantsController
56
- DynamicFieldsController
57
- ].freeze
56
+ # Tenant
57
+ 'Spree::Api::V2::Tenant::CmsPagesController' => { actions: %w[index show].freeze, max_age: :static },
58
+ 'Spree::Api::V2::Tenant::EpisodesController' => { actions: %w[index show].freeze, max_age: :fresh },
59
+ 'Spree::Api::V2::Tenant::HomepageSectionsController' => { actions: %w[index].freeze, max_age: :fresh },
60
+ 'Spree::Api::V2::Tenant::PaymentMethodsController' => { actions: %w[index].freeze, max_age: :fresh },
61
+ 'Spree::Api::V2::Tenant::PopularRoutePlacesController' => { actions: %w[index].freeze, max_age: :fresh },
62
+ 'Spree::Api::V2::Tenant::ProductsController' => { actions: %w[index show].freeze, max_age: :fresh },
63
+ 'Spree::Api::V2::Tenant::ShowContestantsController' => { actions: %w[show].freeze, max_age: :fresh },
64
+ 'Spree::Api::V2::Tenant::ShowEliminationSessionsController' => { actions: %w[index].freeze, max_age: :fresh },
65
+ 'Spree::Api::V2::Tenant::ShowPeopleController' => { actions: %w[index].freeze, max_age: :fresh },
66
+ 'Spree::Api::V2::Tenant::ShowPersonAssignmentsController' => { actions: %w[index].freeze, max_age: :fresh },
67
+ 'Spree::Api::V2::Tenant::ShowsController' => { actions: %w[show].freeze, max_age: :fresh },
68
+ 'Spree::Api::V2::Tenant::TaxonsController' => { actions: %w[index show].freeze, max_age: :fresh },
69
+ 'Spree::Api::V2::Tenant::TripSearchController' => { actions: %w[index].freeze, max_age: :fresh },
70
+ 'Spree::Api::V2::Tenant::TripsController' => { actions: %w[show].freeze, max_age: :fresh },
71
+ 'Spree::Api::V2::Tenant::VendorPhotosController' => { actions: %w[index].freeze, max_age: :fresh },
72
+ 'Spree::Api::V2::Tenant::VendorsController' => { actions: %w[index show].freeze, max_age: :fresh },
73
+ 'Spree::Api::V2::Tenant::VotePackagesController' => { actions: %w[index].freeze, max_age: :fresh },
74
+ 'Spree::Api::V2::Tenant::VotingContestantsController' => { actions: %w[index].freeze, max_age: :fresh }
75
+ }.freeze
58
76
 
59
- # Override This Method based on your UseCase
60
- # Or let it auto-detect based on controller name
61
- def client_max_age
62
- controller_name = self.class.name.demodulize
63
-
64
- if STATIC_CONTENT_CONTROLLERS.include?(controller_name)
65
- ENV.fetch('CACHE_STATIC_MAX_AGE', 1.day.to_i).to_i
66
- elsif SEMI_STATIC_CONTROLLERS.include?(controller_name)
67
- ENV.fetch('CACHE_SEMI_STATIC_MAX_AGE', 1.hour.to_i).to_i
68
- elsif MODERATE_FRESHNESS_CONTROLLERS.include?(controller_name)
69
- ENV.fetch('CACHE_MODERATE_MAX_AGE', 30.minutes.to_i).to_i
70
- elsif HIGH_FRESHNESS_CONTROLLERS.include?(controller_name)
71
- ENV.fetch('CACHE_REALTIME_MAX_AGE', 5.minutes.to_i).to_i
72
- else
73
- ENV.fetch('CACHE_DEFAULT_MAX_AGE', 0.minutes.to_i).to_i
77
+ # CDN (s-maxage) TTL, or nil for an unlisted controller. Override in a controller for a different one.
78
+ def shared_max_age
79
+ case cdn_cache_config&.dig(:max_age)
80
+ when :static then ENV.fetch('CACHE_STATIC_MAX_AGE', 1.day.to_i).to_i
81
+ when :fresh then ENV.fetch('CACHE_REALTIME_MAX_AGE', 5.minutes.to_i).to_i
74
82
  end
75
83
  end
76
84
 
77
- # Defaults to the client TTL so a tier's max-age bounds CDN staleness too: most endpoints
78
- # (e.g. trips, trip search) have no invalidation on change, and an unlisted controller
79
- # (max-age 0) must not be CDN-cached at all.
80
- def shared_max_age
81
- client_max_age
85
+ def cdn_cacheable?
86
+ request.get? && cdn_cache_config.present? && cdn_cache_config[:actions].include?(action_name)
82
87
  end
83
88
 
84
89
  def set_cache_control_for_cdn
85
90
  return if response.committed?
86
91
  return if response.status != 200
92
+ return unless cdn_cacheable?
93
+
94
+ # max-age=0 makes the app revalidate with CloudFront on every request, since a CloudFront
95
+ # invalidation can never reach the copy already on a device. Rack::ETag tags each response,
96
+ # so a revalidation is a small 304. s-maxage alone decides how stale a response can get.
97
+ response.headers['Cache-Control'] = "public, max-age=0, s-maxage=#{shared_max_age}"
98
+ end
99
+
100
+ # Refuses a listed action when it did not come through CloudFront, so the CDN can't be skipped by
101
+ # calling the origin directly. Off while CDN_ORIGIN_SECRETS is unset. It takes a comma-separated
102
+ # list so a new secret can be added to CloudFront before the old one is dropped. With
103
+ # CDN_ORIGIN_REPORT_ONLY=yes it only logs, to find clients still calling the origin before enforcing.
104
+ def verify_cdn_origin!
105
+ secrets = ENV.fetch('CDN_ORIGIN_SECRETS', '').split(',').map(&:strip).compact_blank
106
+ return if secrets.empty?
107
+
108
+ provided = request.headers[CDN_ORIGIN_HEADER].to_s
109
+ return if secrets.any? { |secret| ActiveSupport::SecurityUtils.secure_compare(provided, secret) }
110
+
111
+ if ENV['CDN_ORIGIN_REPORT_ONLY'] == 'yes'
112
+ CmAppLogger.log(
113
+ label: 'cdn_origin_unverified',
114
+ data: {
115
+ controller: self.class.name,
116
+ action: action_name,
117
+ path: request.path,
118
+ header_present: provided.present?,
119
+ via_cloudfront: request.headers['X-Amz-Cf-Id'].present?
120
+ }
121
+ )
122
+ return
123
+ end
124
+
125
+ head :forbidden
126
+ end
87
127
 
88
- # max-age: browser/client cache, s-maxage: CDN cache
89
- response.headers['Cache-Control'] = "public, max-age=#{client_max_age}, s-maxage=#{shared_max_age}"
128
+ private
90
129
 
91
- # Expires: Absolute timestamp when cache becomes stale (HTTP/1.0 fallback)
92
- response.headers['Expires'] = (Time.zone.now + client_max_age).httpdate
130
+ def cdn_cache_config
131
+ CDN_CACHEABLE_ACTIONS[self.class.name.to_s]
93
132
  end
94
133
  end
95
134
  end
@@ -7,11 +7,30 @@ module SpreeCmCommissioner
7
7
  end
8
8
 
9
9
  def ensure_waiting_room_session_token
10
- return if ENV['WAITING_ROOM_DISABLED'] == 'yes'
11
- return if request_from_client?
10
+ return if waiting_room_disabled?
12
11
  return unless required_waiting_room_session?
12
+ return if active_waiting_room_session?
13
13
 
14
- return if SpreeCmCommissioner::WaitingRoomSession.active.exists?(token: params[:waiting_room_session_token])
14
+ handle_waiting_room_session_token_error
15
+ end
16
+
17
+ # Product-based counterpart to the path-based check above, for draft-order endpoints: gating by
18
+ # path would hold every booking (hotel, transit, ...) behind the waiting room, while this only
19
+ # holds orders for products that opt in via Product#enforce_waiting_room_session?. Call it as a
20
+ # before_action on #create, next to ensure_anonymous_booking_allowed!.
21
+ #
22
+ # Products covered by the request's private sale link (`st`) are exempt: that buyer draws from a
23
+ # pool locked for them, not the public stock the queue is rationing. The exemption is per
24
+ # request, not per product — the same product is usually on public sale too.
25
+ #
26
+ # products: [Array<Spree::Product>] every distinct product referenced by this request.
27
+ def ensure_waiting_room_session!(products)
28
+ return if waiting_room_disabled?
29
+
30
+ private_sale_product_ids = waiting_room_private_sale&.product_ids || []
31
+ enforced = products.select(&:enforce_waiting_room_session?).reject { |product| private_sale_product_ids.include?(product.id) }
32
+ return if enforced.empty?
33
+ return if active_waiting_room_session?
15
34
 
16
35
  handle_waiting_room_session_token_error
17
36
  end
@@ -23,13 +42,27 @@ module SpreeCmCommissioner
23
42
  apis.any? { |prefix| request.path.start_with?(prefix) }
24
43
  end
25
44
 
26
- # temporary disable session check from any client server requests.
27
- def request_from_client?
28
- request.headers['X-Cm-Api-Client-Version'].present?
29
- end
30
-
31
45
  def handle_waiting_room_session_token_error
32
46
  render_error_payload(Spree.t(:invalid_session_token), 400)
33
47
  end
48
+
49
+ private
50
+
51
+ def waiting_room_disabled?
52
+ ENV['WAITING_ROOM_DISABLED'] == 'yes'
53
+ end
54
+
55
+ def active_waiting_room_session?
56
+ SpreeCmCommissioner::WaitingRoomSession.active.exists?(token: params[:waiting_room_session_token])
57
+ end
58
+
59
+ # Verified, not just present: a made-up `st` must not skip the queue, so a token that fails
60
+ # Authorize simply earns no exemption.
61
+ def waiting_room_private_sale
62
+ return if params[:st].blank?
63
+
64
+ result = SpreeCmCommissioner::PrivateSales::Authorize.call(token: params[:st], user: spree_current_user)
65
+ result.value if result.success?
66
+ end
34
67
  end
35
68
  end
@@ -0,0 +1,26 @@
1
+ module Spree
2
+ module Admin
3
+ # Behind every admin clear-cache button (spree/admin/shared/_api_cache_button). Admin-only through
4
+ # BaseController#authorize_admin, which checks :api_caches since there is no model class.
5
+ class ApiCachesController < Spree::Admin::BaseController
6
+ # POST /admin/api_caches/clear?model=Spree::Product&id=1, or without id to clear every record of
7
+ # model on one storefront: tenant_id for that tenant's API, none for the BookMe+ storefront.
8
+ def clear
9
+ result = if params[:id].present?
10
+ SpreeCmCommissioner::ApiCaches::Invalidate.call(model: params[:model], id: params[:id])
11
+ else
12
+ tenant = SpreeCmCommissioner::Tenant.find(params[:tenant_id]) if params[:tenant_id].present?
13
+ SpreeCmCommissioner::ApiCaches::InvalidateAll.call(model: params[:model], tenant: tenant)
14
+ end
15
+
16
+ if result.success?
17
+ flash[:success] = Spree.t(:clear_cache_requested)
18
+ else
19
+ flash[:error] = result.error.to_s
20
+ end
21
+
22
+ redirect_back fallback_location: admin_root_path
23
+ end
24
+ end
25
+ end
26
+ end
@@ -31,20 +31,6 @@ module Spree
31
31
  nil
32
32
  end
33
33
 
34
- # POST
35
- def invalidate_api_caches
36
- if params[:model].present?
37
- result = SpreeCmCommissioner::ApiCaches::Invalidate.call(
38
- model: params[:model],
39
- id: params[:id]
40
- )
41
-
42
- flash[:error] = result.error if result.failure?
43
- end
44
-
45
- redirect_back fallback_location: admin_root_path
46
- end
47
-
48
34
  private
49
35
 
50
36
  # See SpreeCmCommissioner::Current#actor -- lets StoreCreditEventDecorator's after_create
@@ -6,6 +6,7 @@ module Spree
6
6
 
7
7
  before_action :load_order
8
8
  before_action :load_guest, only: %i[check_in uncheck_in]
9
+ before_action :load_edit_data, only: :edit
9
10
 
10
11
  def model_class
11
12
  SpreeCmCommissioner::Guest
@@ -41,10 +42,17 @@ module Spree
41
42
  @kyc_fields = @line_item.kyc_fields
42
43
  end
43
44
 
44
- def edit
45
- @kyc_fields = @object.line_item.kyc_fields
46
- @check_ins = @object.check_ins.includes(:check_in_session, :check_in_by)
47
- @eligible_check_in_sessions = @object.eligible_check_in_sessions.order(:position)
45
+ def update
46
+ result = SpreeCmCommissioner::Guests::Update.call(guest: @object, guest_params: update_params)
47
+
48
+ if result.success?
49
+ flash[:success] = flash_message_for(@object, :successfully_updated)
50
+ redirect_to location_after_save
51
+ else
52
+ flash.now[:error] = result.error.to_s
53
+ load_edit_data
54
+ render :edit, status: :unprocessable_entity
55
+ end
48
56
  end
49
57
 
50
58
  def check_in
@@ -88,6 +96,37 @@ module Spree
88
96
  )
89
97
  end
90
98
 
99
+ # Saved through Guests::Update's single transaction so a failing custom field does not leave
100
+ # KYC or seat_number half-saved.
101
+ def update_params
102
+ attrs = permitted_resource_params.to_h.symbolize_keys
103
+ dynamic_fields = submitted_dynamic_fields
104
+ return attrs if dynamic_fields.blank?
105
+
106
+ attrs.merge(
107
+ guest_dynamic_fields_attributes: SpreeCmCommissioner::Guests::BuildDynamicFieldAttributes.call(
108
+ guest: @object, dynamic_fields: dynamic_fields
109
+ )
110
+ )
111
+ end
112
+
113
+ # The form lists every field of every stage, so blank scalar answers mean "not filled in" and
114
+ # are skipped rather than failing the presence validation (clearing an answer is not supported).
115
+ def submitted_dynamic_fields
116
+ raw = params.require(object_name).permit(dynamic_fields: {})[:dynamic_fields]
117
+
118
+ raw.to_h.reject { |_id, value| value.is_a?(String) && value.strip.empty? }
119
+ end
120
+
121
+ def load_edit_data
122
+ @kyc_fields = @object.line_item.kyc_fields
123
+ @check_ins = @object.check_ins.includes(:check_in_session, :check_in_by)
124
+ @eligible_check_in_sessions = @object.eligible_check_in_sessions.order(:position)
125
+ @dynamic_fields = @object.line_item.product.dynamic_fields.includes(:dynamic_field_options)
126
+ .reorder(:data_fill_stage, :position)
127
+ @guest_dynamic_fields = @object.guest_dynamic_fields.to_a
128
+ end
129
+
91
130
  def load_guest
92
131
  @guest = @order.guests.find(params[:id])
93
132
  end
@@ -4,6 +4,8 @@ module Spree
4
4
  helper SpreeCmCommissioner::Admin::HomepageSegmentHelper
5
5
 
6
6
  before_action :load_tenants, only: [:index]
7
+ before_action :reject_invalid_section_type, only: %i[create update]
8
+ before_action :build_assets, only: %i[create update]
7
9
 
8
10
  helper_method :selected_tenant
9
11
 
@@ -39,8 +41,62 @@ module Spree
39
41
  end
40
42
  end
41
43
 
44
+ def remove_app_background
45
+ remove_asset(@object.app_background)
46
+ end
47
+
48
+ def remove_web_background
49
+ remove_asset(@object.web_background)
50
+ end
51
+
42
52
  private
43
53
 
54
+ # Always redirects back to `edit` rather than re-rendering it on failure (unlike the
55
+ # sibling GuestCardClassesController#remove_asset) — this controller's `edit` action sets
56
+ # extra ivars (`@homepage_section_relatables`) that this action never loads, so rendering
57
+ # the template directly here would raise.
58
+ def remove_asset(asset)
59
+ if asset&.destroy
60
+ flash[:success] = Spree.t('notice_messages.icon_removed')
61
+ else
62
+ flash[:error] = Spree.t('errors.messages.cannot_remove_icon')
63
+ end
64
+
65
+ redirect_to edit_admin_homepage_feed_homepage_section_url(@object)
66
+ end
67
+
68
+ # Only `trending` sections use these assets; building them for another type would let a
69
+ # direct API call attach a background the form never shows and the model then has to
70
+ # discard on save (see HomepageSection#clear_non_trending_fields). Either way the raw
71
+ # upload must be pulled out of permitted_resource_params — left in place, it gets assigned
72
+ # straight to the has_one association by update_resource/create_resource, which raises
73
+ # ActiveRecord::AssociationTypeMismatch instead of being silently ignored.
74
+ def build_assets
75
+ unless trending_section?
76
+ permitted_resource_params.delete(:app_background)
77
+ permitted_resource_params.delete(:web_background)
78
+ return
79
+ end
80
+
81
+ @object.build_app_background(attachment: permitted_resource_params.delete(:app_background)) if permitted_resource_params[:app_background]
82
+ @object.build_web_background(attachment: permitted_resource_params.delete(:web_background)) if permitted_resource_params[:web_background]
83
+ end
84
+
85
+ def trending_section?
86
+ (permitted_resource_params[:section_type].presence || @object.section_type).to_s == 'trending'
87
+ end
88
+
89
+ # `enum section_type:` raises ArgumentError on assignment of an unrecognized value, which
90
+ # would otherwise 500 instead of a normal validation failure — strip it here and flash
91
+ # instead, before it ever reaches create_resource/update_resource's mass-assignment.
92
+ def reject_invalid_section_type
93
+ submitted = permitted_resource_params[:section_type]
94
+ return if submitted.blank? || SpreeCmCommissioner::HomepageSection.section_types.key?(submitted.to_s)
95
+
96
+ permitted_resource_params.delete(:section_type)
97
+ flash[:error] = Spree.t('errors.messages.invalid_section_type')
98
+ end
99
+
44
100
  def collection
45
101
  return @collection if defined?(@collection)
46
102
 
@@ -57,16 +113,18 @@ module Spree
57
113
  @collection = scoped.page(params[:page]).per(params[:per_page])
58
114
  end
59
115
 
60
- # overrided
116
+ # overrided — memoized so `.delete()` in build_assets mutates the same hash instance
61
117
  def permitted_resource_params
118
+ return @permitted_resource_params if defined?(@permitted_resource_params)
119
+
62
120
  object_params = params[:spree_cm_commissioner_homepage_section]
63
121
  segment_value = helpers.calculate_segment_value(object_params)
64
122
  mini_app_value = helpers.calculate_mini_app_value(object_params)
65
123
 
66
- params
67
- .require(:spree_cm_commissioner_homepage_section)
68
- .permit(:title, :description, :active, :preview)
69
- .merge(segment: segment_value, mini_app: mini_app_value)
124
+ @permitted_resource_params = params
125
+ .require(:spree_cm_commissioner_homepage_section)
126
+ .permit(:title, :description, :active, :preview, :section_type, :font, :app_background, :web_background)
127
+ .merge(segment: segment_value, mini_app: mini_app_value)
70
128
  end
71
129
 
72
130
  # The storefront being viewed; no tenant in the URL means BookMe+. Also gates the mini app
@@ -8,6 +8,7 @@ module Spree
8
8
  include SpreeCmCommissioner::AnonymousBookingGuard
9
9
 
10
10
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
11
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
11
12
 
12
13
  # A top-up credits a wallet, which must belong to a real user -- unlike #create,
13
14
  # this action has no guest-checkout path.
@@ -124,9 +125,12 @@ module Spree
124
125
  ).fetch(:line_items, [])
125
126
  end
126
127
 
128
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
127
129
  def draft_order_products
128
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
129
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
130
+ @draft_order_products ||= begin
131
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
132
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
133
+ end
130
134
  end
131
135
 
132
136
  # override
@@ -23,6 +23,7 @@ module Spree
23
23
  .where(preview: false)
24
24
  .for_feed(params[:homepage_id] || :general, params[:mini_app])
25
25
  .order(position: :asc)
26
+ .includes(:app_background, :web_background)
26
27
  end
27
28
  end
28
29
  end
@@ -13,6 +13,7 @@ module Spree
13
13
  .where(preview: true)
14
14
  .where(id: SpreeCmCommissioner::HomepageSectionRelatable.section_ids_previewable_by(spree_current_user))
15
15
  .order(position: :asc)
16
+ .includes(:app_background, :web_background)
16
17
  end
17
18
  end
18
19
  end
@@ -9,6 +9,7 @@ module Spree
9
9
  include SpreeCmCommissioner::AnonymousBookingGuard
10
10
 
11
11
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
12
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
12
13
 
13
14
  # Accepts a draft order request without building it: writes a queued Firestore status
14
15
  # document and schedules the real build (SpreeCmCommissioner::DraftOrders::Create) on a
@@ -59,9 +60,12 @@ module Spree
59
60
  .fetch(:line_items, [])
60
61
  end
61
62
 
63
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
62
64
  def draft_order_products
63
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
64
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
65
+ @draft_order_products ||= begin
66
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
67
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
68
+ end
65
69
  end
66
70
  end
67
71
  end
@@ -6,6 +6,7 @@ module Spree
6
6
  include SpreeCmCommissioner::AnonymousBookingGuard
7
7
 
8
8
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
9
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
9
10
 
10
11
  # A top-up credits a wallet, which must belong to a real user -- unlike #create,
11
12
  # this action has no guest-checkout path.
@@ -90,9 +91,12 @@ module Spree
90
91
  .fetch(:line_items, [])
91
92
  end
92
93
 
94
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
93
95
  def draft_order_products
94
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
95
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
96
+ @draft_order_products ||= begin
97
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
98
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
99
+ end
96
100
  end
97
101
 
98
102
  # override
@@ -8,6 +8,7 @@ module Spree
8
8
  .where(preview: false)
9
9
  .active
10
10
  .order(position: :asc)
11
+ .includes(:app_background, :web_background)
11
12
  end
12
13
 
13
14
  def scope
@@ -12,6 +12,7 @@ module Spree
12
12
  .where(preview: true)
13
13
  .where(id: SpreeCmCommissioner::HomepageSectionRelatable.section_ids_previewable_by(spree_current_user))
14
14
  .order(position: :asc)
15
+ .includes(:app_background, :web_background)
15
16
  end
16
17
  end
17
18
  end