spree_cm_commissioner 2.10.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +1 -1
  4. data/app/controllers/spree/admin/system/waiting_room_sessions_controller.rb +27 -0
  5. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +1 -1
  6. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +1 -1
  7. data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +1 -1
  8. data/app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb +1 -1
  9. data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +14 -4
  10. data/app/jobs/spree_cm_commissioner/voting_session_auto_open_job.rb +14 -0
  11. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +25 -0
  12. data/app/models/concerns/spree_cm_commissioner/purchase_limitable.rb +22 -0
  13. data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +25 -7
  14. data/app/models/spree_cm_commissioner/block.rb +1 -0
  15. data/app/models/spree_cm_commissioner/guest.rb +5 -0
  16. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +20 -0
  17. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -1
  18. data/app/models/spree_cm_commissioner/price_decorator.rb +16 -0
  19. data/app/models/spree_cm_commissioner/product_decorator.rb +36 -0
  20. data/app/models/spree_cm_commissioner/seat_layout.rb +46 -9
  21. data/app/models/spree_cm_commissioner/seat_section.rb +47 -0
  22. data/app/models/spree_cm_commissioner/taxon_decorator.rb +8 -0
  23. data/app/models/spree_cm_commissioner/ticket_transfer.rb +4 -0
  24. data/app/models/spree_cm_commissioner/trip.rb +1 -1
  25. data/app/models/spree_cm_commissioner/variant_decorator.rb +17 -0
  26. data/app/models/spree_cm_commissioner/vote.rb +3 -0
  27. data/app/models/spree_cm_commissioner/voting_contestant.rb +1 -0
  28. data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
  29. data/app/models/spree_cm_commissioner/voting_session.rb +15 -0
  30. data/app/models/spree_cm_commissioner/waiting_room_session.rb +20 -0
  31. data/app/overrides/spree/admin/products/_form/purchase_limits.html.erb.deface +49 -0
  32. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +2 -1
  33. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +1 -2
  34. data/app/serializers/spree/v2/tenant/voting_credit_transaction_serializer.rb +1 -0
  35. data/app/serializers/spree_cm_commissioner/v2/storefront/block_serializer.rb +1 -1
  36. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_serializer.rb +1 -0
  37. data/app/serializers/spree_cm_commissioner/v2/storefront/seat_layout_serializer.rb +1 -1
  38. data/app/serializers/spree_cm_commissioner/v2/storefront/seat_section_serializer.rb +2 -1
  39. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +3 -0
  40. data/app/services/spree_cm_commissioner/checkout/complete_decorator.rb +16 -5
  41. data/app/services/spree_cm_commissioner/{draft_order → draft_orders}/create.rb +2 -2
  42. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +29 -7
  43. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +56 -20
  44. data/app/services/spree_cm_commissioner/seat_layouts/sanitize_svg.rb +215 -0
  45. data/app/services/spree_cm_commissioner/seat_layouts/update.rb +79 -0
  46. data/app/services/spree_cm_commissioner/ticket_transfers/accept.rb +5 -4
  47. data/app/services/spree_cm_commissioner/vote_credit_deductor.rb +1 -0
  48. data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +10 -4
  49. data/app/services/spree_cm_commissioner/voting_sessions/auto_open.rb +38 -0
  50. data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +17 -3
  51. data/app/services/spree_cm_commissioner/voting_sessions/publish_live_state.rb +15 -0
  52. data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +32 -23
  53. data/app/services/spree_cm_commissioner/voting_sessions/schedule_vote_window_jobs.rb +45 -0
  54. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +16 -4
  55. data/app/views/spree/admin/system/waiting_room/show.html.erb +1 -0
  56. data/app/views/spree/admin/system/waiting_room_sessions/index.html.erb +118 -0
  57. data/app/views/spree/admin/variants/fixture.html.erb +3 -3
  58. data/config/initializers/spree_permitted_attributes.rb +10 -0
  59. data/config/load_test_flows/platform.yml +20 -0
  60. data/config/locales/en.yml +30 -6
  61. data/config/locales/km.yml +30 -6
  62. data/config/routes.rb +6 -0
  63. data/db/migrate/20260813100000_add_guide_to_cm_seat_layouts.rb +12 -0
  64. data/db/migrate/20260813100001_add_sub_seat_layout_reference_to_cm_seat_sections.rb +7 -0
  65. data/db/migrate/20260813100002_add_shape_path_to_cm_blocks.rb +13 -0
  66. data/db/migrate/20260813100003_add_metadata_to_cm_seat_sections.rb +6 -0
  67. data/db/migrate/20260814000000_add_contestant_id_to_cm_voting_credit_transactions.rb +7 -0
  68. data/db/migrate/20260817000000_add_eliminated_voting_session_to_cm_voting_contestants.rb +9 -0
  69. data/db/migrate/20260817072341_add_metadata_to_cm_seat_layouts.rb +6 -0
  70. data/db/migrate/20260819100000_add_exit_at_to_cm_waiting_room_sessions.rb +5 -0
  71. data/lib/spree_cm_commissioner/test_helper/factories/block_factory.rb +4 -0
  72. data/lib/spree_cm_commissioner/test_helper/factories/seat_layout_factory.rb +12 -0
  73. data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +6 -0
  74. data/lib/spree_cm_commissioner/test_helper/factories/voting_credit_transaction_factory.rb +4 -0
  75. data/lib/spree_cm_commissioner/version.rb +1 -1
  76. metadata +20 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34524289f2fc489da23a30fcaca4bfcdac167a60b0efd382368efda96433e4d9
4
- data.tar.gz: 47cd7bcb029126c27c0dd1c7081f26b8a217e740be768662f59a208a0e440ad6
3
+ metadata.gz: 58bbc65915f1f69096fabf6a1778dd9d810ebdbf8e788c2b1597e3b2173f2204
4
+ data.tar.gz: c32858179db78672e07bc34c5f7395b59b228d5585e35647c3d5486cf0c0ea9c
5
5
  SHA512:
6
- metadata.gz: 134f803e39f5fce95401a2404cf6f559baf6997651440578767022cc9c7c4ddf1368da4b16ec12b096753569794593a03a8ace0ac1522fab107a0ae57b09db04
7
- data.tar.gz: d8401ff0a245a5429919380ffc56412e2a09614fe82a78bef24f2e90ff54d88feb885ea0bb78993d2f45e9e7cc6a294958642290e53f8ea41650fc3ca4652759
6
+ metadata.gz: 6493ccc10ddcb0dfae06115f11168207bdee8f8d687367108ba59e41885e10a989f8adcb17a220019039ef3a5c224ab3fe4356c6f1e90ae9130e4a607347e015
7
+ data.tar.gz: 363d64fd5b2aa2f809163134dd050b85d46d529fe35a34994381c3bc7861acd4f57afab353fdd2772081e019a5f49eb78f4d9141ee9d7a44e06991b07170d764
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.10.1)
37
+ spree_cm_commissioner (2.11.0)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -14,7 +14,7 @@ module SpreeCmCommissioner
14
14
  CountriesController
15
15
  ProvincesController
16
16
  GuestCardClassesController
17
- SeatLayoutController
17
+ SeatLayoutsController
18
18
  CmsPagesController
19
19
  AdvertisementsController
20
20
  ].freeze
@@ -0,0 +1,27 @@
1
+ module Spree
2
+ module Admin
3
+ module System
4
+ class WaitingRoomSessionsController < Spree::Admin::BaseController
5
+ def index
6
+ @filter = params[:filter] if params[:filter] == 'load_test'
7
+
8
+ sessions = SpreeCmCommissioner::WaitingRoomSession.active
9
+ sessions = sessions.load_test if @filter == 'load_test'
10
+
11
+ @waiting_room_sessions = sessions.order(created_at: :desc).page(params[:page]).per(50)
12
+ end
13
+
14
+ # Kills only the sessions the admin checked (Gmail-style select + delete), instead of the
15
+ # room's whole active set. Firebase's active_sessions/full reflect this only on the next
16
+ # WaitingGuestsCaller run (they're a once-a-minute mirror, not live).
17
+ def kill
18
+ ids = Array(params[:ids])
19
+ count = SpreeCmCommissioner::WaitingRoomSession.kill_by_ids!(ids)
20
+ flash[:success] = "Killed #{count} waiting-room session(s)."
21
+
22
+ redirect_back fallback_location: admin_system_waiting_room_sessions_path
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -11,7 +11,7 @@ module Spree
11
11
  # Params:
12
12
  # - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
13
13
  def create
14
- result = SpreeCmCommissioner::DraftOrder::Create.call(
14
+ result = SpreeCmCommissioner::DraftOrders::Create.call(
15
15
  line_items: draft_order_line_items,
16
16
  user: spree_current_user,
17
17
  store: current_store,
@@ -8,7 +8,7 @@ module Spree
8
8
  # Params:
9
9
  # - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
10
10
  def create
11
- result = SpreeCmCommissioner::DraftOrder::Create.call(
11
+ result = SpreeCmCommissioner::DraftOrders::Create.call(
12
12
  line_items: draft_order_line_items,
13
13
  user: spree_current_user,
14
14
  store: current_store,
@@ -13,7 +13,7 @@ module Spree
13
13
  def collection
14
14
  @collection ||= @voting_session
15
15
  .voting_contestants
16
- .includes(:voting_session, { show_contestant: :assigned_show_person },
16
+ .includes(:voting_session, :eliminated_voting_session, { show_contestant: :assigned_show_person },
17
17
  :show_contestant_images, :show_contestant_videos,
18
18
  :advanced_to, :advanced_from
19
19
  ).order(:vote_number)
@@ -20,7 +20,7 @@ module Spree
20
20
 
21
21
  def scope
22
22
  scope = spree_current_user.voting_credit_transactions
23
- .includes(:vote_credit, :originator)
23
+ .includes(:vote_credit, :originator, contestant: :voting_session)
24
24
  .order(created_at: :desc)
25
25
 
26
26
  scope = scope.by_action(params[:actions].split(',')) if params[:actions].present?
@@ -56,21 +56,31 @@ module SpreeCmCommissioner
56
56
  option_value.name
57
57
  end
58
58
 
59
- # Builds the load-test fixture for this variant so it can be used by the k6 booking flow.
59
+ # Builds the load-test fixture for this variant so it can be used by a k6 booking flow.
60
60
  # Shown on a page (with a check for whether the variant can be booked) for the tester to
61
61
  # copy into the load_testing config. A product with a tenant_id belongs to a tenant
62
62
  # (SaaS-hosted) store rather than the platform — the two k6 workflows keep separate fixture
63
63
  # folders (load_testing/config/fixture/platform/booking_ticket_flow/ vs
64
64
  # load_testing/config/fixture/tenant/cash_on_booking_ticket_flow/ — the tenant flow is named
65
65
  # cash_on_* since it has an ABA sibling; the platform flow doesn't).
66
+ #
67
+ # Whether this variant needs seat_selection_booking_flow isn't picked here — it's read off
68
+ # the generated fixture's metadata[:select_seat] (see GenerateBookingTicketData), which
69
+ # derives it the same way add-to-cart itself does.
66
70
  def fixture
67
71
  @fixture_workflow = @variant.product.tenant_id.present? ? 'tenant' : 'platform'
68
- @fixture_flow = @fixture_workflow == 'tenant' ? 'cash_on_booking_ticket_flow' : 'booking_ticket_flow'
72
+ @fixture_env = Rails.env.development? ? 'local' : Rails.env.to_s # the load_testing config calls it "local", not "development"
73
+
69
74
  result = SpreeCmCommissioner::LoadTest::GenerateBookingTicketData.call(variant: @variant)
70
75
  @fixture = result.value[:data]
71
76
  @fixture_issues = result.value[:issues]
72
- # The load_testing config calls it "local", not "development".
73
- @fixture_env = Rails.env.development? ? 'local' : Rails.env.to_s
77
+ @fixture_flow = @fixture[:metadata][:select_seat] ? 'seat_selection_booking_flow' : fixture_flow_for(@fixture_workflow)
78
+ end
79
+
80
+ private
81
+
82
+ def fixture_flow_for(workflow)
83
+ workflow == 'tenant' ? 'cash_on_booking_ticket_flow' : 'booking_ticket_flow'
74
84
  end
75
85
  end
76
86
  end
@@ -0,0 +1,14 @@
1
+ module SpreeCmCommissioner
2
+ # Enqueued by VotingSessions::ScheduleVoteWindowJobs with wait_until: vote_open_at.
3
+ # See VotingSessions::AutoOpen for the actual open/publish logic.
4
+ class VotingSessionAutoOpenJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(voting_session_id)
8
+ voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
9
+ return if voting_session.nil?
10
+
11
+ SpreeCmCommissioner::VotingSessions::AutoOpen.call(voting_session: voting_session)
12
+ end
13
+ end
14
+ end
@@ -41,6 +41,19 @@ module SpreeCmCommissioner
41
41
  store_public_metadata :preload_seat_layout_ids, :array, default: []
42
42
  store_public_metadata :preload_active_seat_layout_ids, :array, default: []
43
43
 
44
+ # Use to indicate if a seat layout is the guide layout for this event. 1 guide layout per event.
45
+ store_public_metadata :preload_guide_seat_layout_id, :integer
46
+ store_public_metadata :preload_active_guide_seat_layout_id, :integer
47
+
48
+ # Denormalised price range across the event's variants, so the client can show it (e.g. "from $X")
49
+ # without loading every variant. Kept in sync by PriceDecorator's after_commit hook
50
+ store_public_metadata :min_price, :string
51
+ store_public_metadata :max_price, :string
52
+ store_public_metadata :currency, :string
53
+
54
+ store_public_metadata :spotify_embed_url, :string
55
+ store_public_metadata :spotify_embed_skeleton_color, :string, default: '#900033'
56
+
44
57
  before_validation :validate_at_least_one_check_in_flow_presence
45
58
  end
46
59
 
@@ -51,6 +64,18 @@ module SpreeCmCommissioner
51
64
  flows
52
65
  end
53
66
 
67
+ def display_min_price
68
+ return nil if min_price.blank?
69
+
70
+ Spree::Money.new(min_price, currency: currency).to_s
71
+ end
72
+
73
+ def display_max_price
74
+ return nil if max_price.blank?
75
+
76
+ Spree::Money.new(max_price, currency: currency).to_s
77
+ end
78
+
54
79
  private
55
80
 
56
81
  def validate_at_least_one_check_in_flow_presence
@@ -12,6 +12,14 @@ module SpreeCmCommissioner
12
12
  max_quantity_per_ip
13
13
  ].freeze
14
14
 
15
+ # Caps that aggregate across orders. The order cap is bounded by each of these:
16
+ # whichever is smaller is what the buyer actually gets.
17
+ AGGREGATE_LIMIT_KEYS = %i[
18
+ max_quantity_per_account
19
+ max_quantity_per_device
20
+ max_quantity_per_ip
21
+ ].freeze
22
+
15
23
  POSITIVE_INTEGER = { only_integer: true, greater_than: 0, message: :must_be_positive_integer }.freeze
16
24
 
17
25
  included do
@@ -27,6 +35,7 @@ module SpreeCmCommissioner
27
35
 
28
36
  validate :validate_max_quantity_per_account
29
37
  validate :validate_max_quantity_per_device
38
+ validate :validate_order_cap_within_aggregate_caps
30
39
 
31
40
  before_validation :normalize_purchase_limits
32
41
  end
@@ -50,6 +59,19 @@ module SpreeCmCommissioner
50
59
  errors.add(:base, I18n.t('purchase_limit.account_cap_requires_named_booking'))
51
60
  end
52
61
 
62
+ # An order cap above an aggregate cap is unreachable - the aggregate check counts the same
63
+ # cart quantity plus past orders, so it rejects first. Only the tightest cap is named:
64
+ # fixing it clears the rest. A blank cap reads back as 0, hence the positive? filter.
65
+ def validate_order_cap_within_aggregate_caps
66
+ order_cap = max_quantity_per_order.to_i
67
+ key = AGGREGATE_LIMIT_KEYS.select { |k| public_send(k).to_i.positive? && public_send(k).to_i < order_cap }
68
+ .min_by { |k| public_send(k).to_i }
69
+ return if key.nil?
70
+
71
+ cap_name = I18n.t("purchase_limit.cap_names.#{key.to_s.delete_prefix('max_quantity_per_')}")
72
+ errors.add(:max_quantity_per_order, I18n.t('purchase_limit.order_cap_exceeds_scope_cap', cap_name: cap_name))
73
+ end
74
+
53
75
  def validate_max_quantity_per_device
54
76
  return unless max_quantity_per_device.to_i.positive? && !purchasable_on_app?
55
77
 
@@ -6,8 +6,15 @@ module SpreeCmCommissioner
6
6
  #
7
7
  # There is no mode flag: the presence of vote_open_at/vote_close_at is the signal.
8
8
  #
9
- # Pre-set at creation — auto-activates: once #activate flips status to enabled, can_vote?
10
- # just compares Time.current against the already-real timestamps.
9
+ # Pre-set at creation — auto-activates: #schedule_vote_window_jobs (below) enqueues
10
+ # VotingSessionAutoOpenJob for vote_open_at. If status is already
11
+ # :enabled, can_vote? was already purely time-driven, so that job just
12
+ # keeps Firestore's published state in sync. If status is :on_air (an
13
+ # organizer revealed the session but never pressed Start Vote), that
14
+ # same job flips status to :enabled once vote_open_at arrives — gated
15
+ # on visible?, so it only completes a reveal the organizer already made,
16
+ # never a private archived session. VotingSessionAutoCloseJob then
17
+ # follows the same way once enabled, for vote_close_at.
11
18
  # Left blank — #start_vote! rewrites them to now / now + vote_duration_seconds the
12
19
  # moment the organizer presses it, live during the broadcast.
13
20
  #
@@ -36,6 +43,7 @@ module SpreeCmCommissioner
36
43
  # yet — "reveal" is something an organizer does to an *existing* archived session.
37
44
  before_update :promote_from_archived_when_made_visible
38
45
  after_commit :publish_live_state_to_firestore, on: %i[create update], if: :live_state_changed?
46
+ after_commit :schedule_vote_window_jobs, on: %i[create update], if: :vote_window_scheduling_relevant_change?
39
47
 
40
48
  scope :visible, -> { where(visible: true) }
41
49
  end
@@ -56,8 +64,6 @@ module SpreeCmCommissioner
56
64
  status: :enabled,
57
65
  visible: true
58
66
  )
59
-
60
- SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
61
67
  end
62
68
 
63
69
  # Ends the vote window immediately and moves to closed. status: closed (not just a rewritten
@@ -104,8 +110,6 @@ module SpreeCmCommissioner
104
110
  # validation.
105
111
  def extend_vote!(seconds)
106
112
  update!(vote_close_at: [vote_close_at, Time.current].max + seconds.to_i.seconds)
107
-
108
- SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
109
113
  end
110
114
 
111
115
  private
@@ -131,7 +135,21 @@ module SpreeCmCommissioner
131
135
  end
132
136
 
133
137
  def publish_live_state_to_firestore
134
- SpreeCmCommissioner::VotingSessionFirestorePublishJob.perform_later(id)
138
+ SpreeCmCommissioner::VotingSessions::PublishLiveState.call(voting_session: self)
139
+ end
140
+
141
+ # Either the window itself moved, or status crossed into/out of a state the scheduling cares
142
+ # about (archived -> on_air on reveal; on_air -> enabled when VotingSessionAutoOpenJob fires).
143
+ # Both matter: a reveal with a vote_open_at already set at creation only just became eligible
144
+ # for the open job, without vote_open_at/vote_close_at themselves changing on this save.
145
+ def vote_window_scheduling_relevant_change?
146
+ saved_change_to_attribute?(:vote_open_at) || saved_change_to_attribute?(:vote_close_at) ||
147
+ saved_change_to_attribute?(:status)
148
+ end
149
+
150
+ # See SpreeCmCommissioner::VotingSessions::ScheduleVoteWindowJobs for the scheduling rules.
151
+ def schedule_vote_window_jobs
152
+ SpreeCmCommissioner::VotingSessions::ScheduleVoteWindowJobs.call(voting_session: self)
135
153
  end
136
154
  end
137
155
  end
@@ -24,6 +24,7 @@ module SpreeCmCommissioner
24
24
  validates :x, presence: true, numericality: true
25
25
  validates :y, presence: true, numericality: true
26
26
  validates :rotation, presence: true, numericality: { greater_than_or_equal_to: -360, less_than_or_equal_to: 360 }
27
+ validates :shape_path, format: { with: /\A\s*[Mm]/, message: 'must start with a move command' }, allow_blank: true # rubocop:disable Rails/I18nLocaleTexts
27
28
 
28
29
  validate :cannot_unassign_variant_with_active_blocks
29
30
 
@@ -303,6 +303,11 @@ module SpreeCmCommissioner
303
303
  Array(public_metadata['eligible_check_in_session_ids']).map(&:to_i)
304
304
  end
305
305
 
306
+ # Set by TicketTransfers::TransferGuest when this guest was created to receive a transferred ticket
307
+ def from_transfer?
308
+ ActiveModel::Type::Boolean.new.cast(public_metadata['from_transfer']) || false
309
+ end
310
+
306
311
  def eligible_check_in_session_ids=(ids)
307
312
  # Ensure public_metadata is a hash
308
313
  self.public_metadata = {} if public_metadata.nil?
@@ -10,6 +10,8 @@ module SpreeCmCommissioner
10
10
  update_conversion_for(product_taxon)
11
11
  end
12
12
 
13
+ refresh_price_range
14
+
13
15
  # These steps can be expensive and our new order state machine already prevents most cases from happening.
14
16
  # So we should only run them as manual maintenance just in case we missed some edge cases.
15
17
  reassign_guests_event_id if manually_triggered?
@@ -22,6 +24,24 @@ module SpreeCmCommissioner
22
24
  maintainable.id
23
25
  end
24
26
 
27
+ # Denormalises maintainable's price range onto EventMetadata#min_price/max_price/currency
28
+ # so the event-details bar can show "from $X" without loading the ticket list. Triggered
29
+ # by PriceDecorator's after_commit hook via a pending task, and run unconditionally here.
30
+ def refresh_price_range
31
+ currency = Spree::Store.default.default_currency
32
+
33
+ priced_variant_ids = maintainable.event_variants.where(is_master: false).select(:id)
34
+ min_amount, max_amount = Spree::Price
35
+ .where(variant_id: priced_variant_ids, currency: currency)
36
+ .pick(Arel.sql('MIN(amount)'), Arel.sql('MAX(amount)'))
37
+
38
+ maintainable.update!(
39
+ min_price: min_amount&.to_s,
40
+ max_price: max_amount&.to_s,
41
+ currency: min_amount.present? ? currency : nil
42
+ )
43
+ end
44
+
25
45
  def update_conversion_for(product_taxon)
26
46
  # Count guests (attendees) instead of line item quantity so this stays consistent
27
47
  # with EventChartQueries, which counts DISTINCT cm_guests.id on complete orders.
@@ -296,7 +296,7 @@ module SpreeCmCommissioner
296
296
 
297
297
  # override spree_vpago method
298
298
  def payment_host
299
- tenant&.payment_url.presence || tenant&.formatted_url.presence || ENV.fetch('DEFAULT_URL_HOST')
299
+ tenant&.payment_url.presence || tenant&.formatted_url.presence || ENV.fetch('PAYMENT_BASE_URL')
300
300
  end
301
301
 
302
302
  # Returns arrays of connected line_item IDs grouped by connected_trip_id.
@@ -2,6 +2,22 @@ module SpreeCmCommissioner
2
2
  module PriceDecorator
3
3
  def self.prepended(base)
4
4
  base.belongs_to :inventory_item, class_name: 'SpreeCmCommissioner::InventoryItem', inverse_of: :prices, optional: true
5
+ base.after_commit :trigger_event_price_range_refresh, if: :should_trigger_event_price_range_refresh?
6
+ end
7
+
8
+ def should_trigger_event_price_range_refresh?
9
+ (saved_change_to_amount? || saved_change_to_currency?) && event_id.present?
10
+ end
11
+
12
+ def event_id
13
+ variant&.product&.event_id
14
+ end
15
+
16
+ def trigger_event_price_range_refresh
17
+ SpreeCmCommissioner::MaintenanceTasks::Event.pending.create_or_find_by(
18
+ maintainable_type: 'Spree::Taxon',
19
+ maintainable_id: event_id
20
+ )
5
21
  end
6
22
  end
7
23
  end
@@ -144,6 +144,7 @@ module SpreeCmCommissioner
144
144
 
145
145
  base.after_update :update_variants_vendor_id, if: :saved_change_to_vendor_id?
146
146
  base.after_update :sync_event_id_to_children, if: :saved_change_to_event_id?
147
+ base.after_commit :trigger_event_price_range_refresh_for_event_change, if: :saved_change_to_event_id?
147
148
 
148
149
  base.enum purchasable_on: { both: 0, web: 1, app: 2 }, _prefix: true
149
150
  base.enum call_to_action: { buy_now: 0,
@@ -162,6 +163,16 @@ module SpreeCmCommissioner
162
163
 
163
164
  # Create maintaining task to purge product related caches
164
165
  base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
166
+
167
+ # App opens the event page, not the ticket/product page, so the event's cache must be purged too.
168
+ base.after_commit do
169
+ [event_id, event_id_before_last_save].compact.uniq.each do |taxon_id|
170
+ SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(
171
+ maintainable_type: 'Spree::Taxon',
172
+ maintainable_id: taxon_id
173
+ )
174
+ end
175
+ end
165
176
  end
166
177
 
167
178
  def ticket_url
@@ -184,6 +195,20 @@ module SpreeCmCommissioner
184
195
  end
185
196
  end
186
197
 
198
+ # override
199
+ # [spree_core] only checks active?/deleted?, so a scheduled product (available_on in the future)
200
+ # reports available even though it isn't purchasable yet. Since Variant#available? delegates
201
+ # here, this closes the gap for every caller (cart, checkout, Stock::AvailabilityChecker) in
202
+ # one place — mirrors VariantDecorator#discontinued?'s window check.
203
+ def available?
204
+ return false unless active?
205
+ return false if deleted?
206
+ return false if discontinued?
207
+ return false if available_on.present? && available_on > Time.current
208
+
209
+ true
210
+ end
211
+
187
212
  private
188
213
 
189
214
  def set_tenant
@@ -202,6 +227,17 @@ module SpreeCmCommissioner
202
227
  ::SpreeCmCommissioner::ProductEventIdToChildrenSyncerJob.perform_later(product_id: id)
203
228
  end
204
229
 
230
+ # Moving a product on/off an event stales both events' price ranges — refresh whichever
231
+ # side(s) actually had an event.
232
+ def trigger_event_price_range_refresh_for_event_change
233
+ [event_id, event_id_before_last_save].compact.uniq.each do |taxon_id|
234
+ SpreeCmCommissioner::MaintenanceTasks::Event.pending.create_or_find_by(
235
+ maintainable_type: 'Spree::Taxon',
236
+ maintainable_id: taxon_id
237
+ )
238
+ end
239
+ end
240
+
205
241
  def validate_event_taxons
206
242
  errors.add(:taxons, 'Event Taxon can\'t not be more than 1') if taxons.event.size > 1
207
243
  errors.add(:taxons, 'Must add event date to taxon') if event.from_date.nil? || event.to_date.nil?
@@ -1,5 +1,13 @@
1
1
  module SpreeCmCommissioner
2
2
  class SeatLayout < Base
3
+ include StoreMetadata
4
+
5
+ # Whole-screen theming for the app's seat-selection scaffold (background / app bar / status
6
+ # bar) — distinct from SeatSection#color, which fills an individual zone shape.
7
+ store_public_metadata :background_color, :string
8
+
9
+ has_one_attached :background_svg
10
+
3
11
  has_many :seat_sections, class_name: 'SpreeCmCommissioner::SeatSection', dependent: :destroy
4
12
 
5
13
  has_many :blocks, class_name: 'SpreeCmCommissioner::Block'
@@ -11,6 +19,13 @@ module SpreeCmCommissioner
11
19
  through: :blocks,
12
20
  source: :active_on_hold_reserved_blocks
13
21
 
22
+ # Reverse side of SeatSection#sub_seat_layout — every section, on any layout, that opens
23
+ # this layout when tapped. Powers the dashboard's "Referenced by …" note and the
24
+ # activation guard that refuses to go live while pointing at a non-active layout.
25
+ has_many :linking_seat_sections, class_name: 'SpreeCmCommissioner::SeatSection',
26
+ foreign_key: :sub_seat_layout_id,
27
+ inverse_of: :sub_seat_layout, dependent: :nullify
28
+
14
29
  belongs_to :layoutable, polymorphic: true
15
30
 
16
31
  enum status: {
@@ -20,6 +35,11 @@ module SpreeCmCommissioner
20
35
  }
21
36
 
22
37
  validates :name, presence: true
38
+ validates :background_color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true
39
+
40
+ # A guided layout is the one flagged to drive an event's/vehicle's customer-facing
41
+ # tap-to-navigate map; at most one per layoutable.
42
+ validates :guide, uniqueness: { scope: %i[layoutable_type layoutable_id] }, if: :guide?
23
43
 
24
44
  accepts_nested_attributes_for :seat_sections, :top_level_blocks, allow_destroy: true
25
45
 
@@ -27,26 +47,43 @@ module SpreeCmCommissioner
27
47
  # DO NOT REMOVE without implementing alternative sync (see event_metadata.rb:L16-24).
28
48
  after_commit :sync_seat_layout_id_to_layoutable!, if: -> { layoutable.present? }
29
49
 
50
+ def background_svg_url
51
+ return nil unless background_svg.attached?
52
+
53
+ Rails.application.routes.url_helpers.cdn_image_url(background_svg)
54
+ end
55
+
56
+ # Any section or block has been converted from the background artwork — i.e. there is
57
+ # something for a customer to tap. Used by the "nothing converted yet" index-card state
58
+ # and the activation guard that refuses to go live with an unmapped background.
59
+ def mapped_sections?
60
+ seat_sections.where.not(shape_path: nil).exists? || blocks.where.not(shape_path: nil).exists?
61
+ end
62
+
30
63
  private
31
64
 
32
- def sync_seat_layout_id_to_layoutable!
65
+ def sync_seat_layout_id_to_layoutable! # rubocop:disable Metrics/PerceivedComplexity
33
66
  # Update layoutable's seat layout metadata after any change (create/update/destroy).
34
67
  # This keeps the cache in sync with the actual seat_layouts association.
35
68
  if layoutable.respond_to?(:seat_layouts)
36
69
  # Event has many seat layouts: `..._ids` caches every layout ever attached (any status);
37
70
  # `active_..._ids` caches only the `active` ones, for callers checking bookability.
38
- layoutable.update!(
39
- preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id),
40
- preload_active_seat_layout_ids: layoutable.seat_layouts.active.pluck(:id)
41
- )
71
+ layoutable.preload_seat_layout_ids = layoutable.seat_layouts.pluck(:id)
72
+ layoutable.preload_active_seat_layout_ids = layoutable.seat_layouts.active.pluck(:id)
42
73
  elsif layoutable.respond_to?(:seat_layout)
43
74
  # Taxon/Vehicle has single seat layout: cache the ID (or nil if destroyed); the active
44
75
  # variant is also nil while the layout is inactive/archived, not just when destroyed.
45
- layoutable.update!(
46
- preload_seat_layout_id: destroyed? ? nil : id,
47
- preload_active_seat_layout_id: destroyed? || !active? ? nil : id
48
- )
76
+ layoutable.preload_seat_layout_id = destroyed? ? nil : id
77
+ layoutable.preload_active_seat_layout_id = destroyed? || !active? ? nil : id
49
78
  end
79
+
80
+ if layoutable.respond_to?(:preload_guide_seat_layout_id=)
81
+ guide_layout = destroyed? ? nil : layoutable.seat_layouts.find_by(guide: true)
82
+ layoutable.preload_guide_seat_layout_id = guide_layout&.id
83
+ layoutable.preload_active_guide_seat_layout_id = guide_layout&.active? ? guide_layout.id : nil
84
+ end
85
+
86
+ layoutable.save!
50
87
  end
51
88
  end
52
89
  end
@@ -1,16 +1,63 @@
1
1
  module SpreeCmCommissioner
2
2
  class SeatSection < Base
3
+ include StoreMetadata
4
+
3
5
  belongs_to :seat_layout, class_name: 'SpreeCmCommissioner::SeatLayout', optional: false
6
+ belongs_to :sub_seat_layout, class_name: 'SpreeCmCommissioner::SeatLayout', optional: true,
7
+ inverse_of: :linking_seat_sections
4
8
 
5
9
  has_many :blocks, class_name: 'SpreeCmCommissioner::Block', dependent: :destroy
6
10
 
11
+ store_public_metadata :product_ids, :array, default: []
12
+ store_public_metadata :background_color, :string
13
+
7
14
  validates :name, presence: true
8
15
  validates :width, presence: true, numericality: { greater_than: 0 }
9
16
  validates :height, presence: true, numericality: { greater_than: 0 }
10
17
  validates :x, presence: true, numericality: true
11
18
  validates :y, presence: true, numericality: true
12
19
  validates :rotation, presence: true, numericality: { greater_than_or_equal_to: -360, less_than_or_equal_to: 360 }
20
+ validates :background_color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true
21
+ validates :shape_path, format: { with: /\A\s*[Mm]/, message: 'must start with a move command' }, allow_blank: true # rubocop:disable Rails/I18nLocaleTexts
22
+
23
+ validate :validate_on_tap_destination
24
+ validate :validate_sub_seat_layout
13
25
 
14
26
  accepts_nested_attributes_for :blocks, allow_destroy: true
27
+
28
+ private
29
+
30
+ # A section's on-tap destination is exactly one of:
31
+ # 1. Nothing — a normal section with blocks; the customer picks seats individually, not
32
+ # the section itself.
33
+ # 2. Open a sub seat layout — sub_seat_layout_id is set.
34
+ # 3. Open a ticket list — product_ids is set.
35
+ #
36
+ # A section with blocks can only ever be case 1; a blockless section can be case 2 or 3, never
37
+ # both — made unreachable from the dashboard's "On tap" selector, enforced here as the source
38
+ # of truth for any write that bypasses it.
39
+ def validate_on_tap_destination
40
+ has_destination = product_ids.present? || sub_seat_layout_id.present?
41
+
42
+ errors.add(:base, 'a section with blocks cannot also have an on-tap destination') if has_destination && blocks.any?
43
+
44
+ return unless product_ids.present? && sub_seat_layout_id.present?
45
+
46
+ errors.add(:base, 'a section can have at most one on-tap destination')
47
+ end
48
+
49
+ def validate_sub_seat_layout
50
+ return if sub_seat_layout.blank?
51
+
52
+ errors.add(:sub_seat_layout, "cannot be the section's own seat layout") if sub_seat_layout_id == seat_layout_id
53
+
54
+ if seat_layout.present? && sub_seat_layout.layoutable != seat_layout.layoutable
55
+ errors.add(:sub_seat_layout, 'must belong to the same event or vehicle as this section')
56
+ end
57
+
58
+ return unless sub_seat_layout.guide?
59
+
60
+ errors.add(:sub_seat_layout, "cannot itself be the layoutable's guide layout")
61
+ end
15
62
  end
16
63
  end
@@ -47,6 +47,8 @@ module SpreeCmCommissioner
47
47
  base.belongs_to :vendor, class_name: 'Spree::Vendor'
48
48
 
49
49
  base.validates_associated :category_icon
50
+ base.validate :validate_event_dates, if: -> { from_date.present? && to_date.present? }
51
+
50
52
  base.before_save :set_kind
51
53
  base.before_save :set_slug
52
54
  base.before_save :normalize_custom_redirect_url
@@ -180,6 +182,12 @@ module SpreeCmCommissioner
180
182
 
181
183
  private
182
184
 
185
+ def validate_event_dates
186
+ return unless from_date > to_date
187
+
188
+ errors.add(:from_date, :must_be_before_to_date)
189
+ end
190
+
183
191
  def normalize_custom_redirect_url
184
192
  self.custom_redirect_url = nil if custom_redirect_url.blank?
185
193
  end
@@ -66,6 +66,10 @@ module SpreeCmCommissioner
66
66
  gift? || manual_payment?
67
67
  end
68
68
 
69
+ def requires_payment?
70
+ !free_transfer? && total.positive?
71
+ end
72
+
69
73
  # Records an audit trail of settlement_status changes to spree_state_changes.
70
74
  # Call this explicitly after a successful save to capture who changed the status and when.
71
75
  def log_settlement_status_changes(user_id:)