spree_cm_commissioner 2.10.2.pre.pre1 → 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 (80) 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/admin/vendor_authorized_users_controller.rb +0 -15
  6. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +1 -1
  7. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +1 -1
  8. data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +1 -1
  9. data/app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb +1 -1
  10. data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +14 -4
  11. data/app/jobs/spree_cm_commissioner/voting_session_auto_open_job.rb +14 -0
  12. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +25 -0
  13. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +6 -21
  14. data/app/models/concerns/spree_cm_commissioner/purchase_limitable.rb +22 -0
  15. data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +2 -2
  16. data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +0 -19
  17. data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +25 -7
  18. data/app/models/spree_cm_commissioner/block.rb +1 -0
  19. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +20 -0
  20. data/app/models/spree_cm_commissioner/order_decorator.rb +13 -1
  21. data/app/models/spree_cm_commissioner/price_decorator.rb +16 -0
  22. data/app/models/spree_cm_commissioner/product_decorator.rb +36 -0
  23. data/app/models/spree_cm_commissioner/seat_layout.rb +46 -9
  24. data/app/models/spree_cm_commissioner/seat_section.rb +47 -0
  25. data/app/models/spree_cm_commissioner/taxon_decorator.rb +8 -0
  26. data/app/models/spree_cm_commissioner/trip.rb +1 -1
  27. data/app/models/spree_cm_commissioner/variant_decorator.rb +17 -0
  28. data/app/models/spree_cm_commissioner/vote.rb +3 -0
  29. data/app/models/spree_cm_commissioner/voting_contestant.rb +1 -0
  30. data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
  31. data/app/models/spree_cm_commissioner/voting_session.rb +15 -0
  32. data/app/models/spree_cm_commissioner/waiting_room_session.rb +20 -0
  33. data/app/overrides/spree/admin/products/_form/purchase_limits.html.erb.deface +49 -0
  34. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +2 -1
  35. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +1 -2
  36. data/app/serializers/spree/v2/tenant/voting_credit_transaction_serializer.rb +1 -0
  37. data/app/serializers/spree_cm_commissioner/v2/storefront/block_serializer.rb +1 -1
  38. data/app/serializers/spree_cm_commissioner/v2/storefront/seat_layout_serializer.rb +1 -1
  39. data/app/serializers/spree_cm_commissioner/v2/storefront/seat_section_serializer.rb +2 -1
  40. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +3 -0
  41. data/app/services/spree_cm_commissioner/{draft_order → draft_orders}/create.rb +2 -2
  42. data/app/services/spree_cm_commissioner/intercity_taxi_order/create.rb +2 -4
  43. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +29 -7
  44. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +56 -20
  45. data/app/services/spree_cm_commissioner/seat_layouts/sanitize_svg.rb +215 -0
  46. data/app/services/spree_cm_commissioner/seat_layouts/update.rb +79 -0
  47. data/app/services/spree_cm_commissioner/ticket_transfers/accept.rb +1 -1
  48. data/app/services/spree_cm_commissioner/transit_order/create.rb +4 -10
  49. data/app/services/spree_cm_commissioner/vote_credit_deductor.rb +1 -0
  50. data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +10 -4
  51. data/app/services/spree_cm_commissioner/voting_sessions/auto_open.rb +38 -0
  52. data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +17 -3
  53. data/app/services/spree_cm_commissioner/voting_sessions/publish_live_state.rb +15 -0
  54. data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +32 -23
  55. data/app/services/spree_cm_commissioner/voting_sessions/schedule_vote_window_jobs.rb +45 -0
  56. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +16 -4
  57. data/app/views/spree/admin/system/waiting_room/show.html.erb +1 -0
  58. data/app/views/spree/admin/system/waiting_room_sessions/index.html.erb +118 -0
  59. data/app/views/spree/admin/variants/fixture.html.erb +3 -3
  60. data/app/views/spree/admin/vendor_authorized_users/index.html.erb +0 -15
  61. data/config/initializers/spree_permitted_attributes.rb +10 -0
  62. data/config/load_test_flows/platform.yml +20 -0
  63. data/config/locales/en.yml +30 -10
  64. data/config/locales/km.yml +30 -6
  65. data/config/routes.rb +6 -0
  66. data/db/migrate/20260813100000_add_guide_to_cm_seat_layouts.rb +12 -0
  67. data/db/migrate/20260813100001_add_sub_seat_layout_reference_to_cm_seat_sections.rb +7 -0
  68. data/db/migrate/20260813100002_add_shape_path_to_cm_blocks.rb +13 -0
  69. data/db/migrate/20260813100003_add_metadata_to_cm_seat_sections.rb +6 -0
  70. data/db/migrate/20260814000000_add_contestant_id_to_cm_voting_credit_transactions.rb +7 -0
  71. data/db/migrate/20260817000000_add_eliminated_voting_session_to_cm_voting_contestants.rb +9 -0
  72. data/db/migrate/20260817072341_add_metadata_to_cm_seat_layouts.rb +6 -0
  73. data/db/migrate/20260819100000_add_exit_at_to_cm_waiting_room_sessions.rb +5 -0
  74. data/lib/spree_cm_commissioner/test_helper/factories/block_factory.rb +4 -0
  75. data/lib/spree_cm_commissioner/test_helper/factories/seat_layout_factory.rb +12 -0
  76. data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +6 -0
  77. data/lib/spree_cm_commissioner/test_helper/factories/voting_credit_transaction_factory.rb +4 -0
  78. data/lib/spree_cm_commissioner/version.rb +1 -1
  79. metadata +22 -6
  80. data/app/models/concerns/spree_cm_commissioner/order_channel_bitwise.rb +0 -70
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5552124388d0792051115573a9cc5e51044c8edcf7f8e89c6f03b43d2ebd0ab
4
- data.tar.gz: '0694645f790079a03ebad09626ca155e7495c37ea8b45f83064a3c31b2aa6f75'
3
+ metadata.gz: 58bbc65915f1f69096fabf6a1778dd9d810ebdbf8e788c2b1597e3b2173f2204
4
+ data.tar.gz: c32858179db78672e07bc34c5f7395b59b228d5585e35647c3d5486cf0c0ea9c
5
5
  SHA512:
6
- metadata.gz: 06b79003d8dee84088c17d6cd6f90b7d2b7d5eeca20b4045ca1775b467b3455b66898e72e47d33e9655a6b8b7c1b36dcef355b8b4ff5258daa066d87e8cdfb02
7
- data.tar.gz: 386411dbfdd18ce5d25ade6d55e045b1bcaea788d3b8cb49437723789ab8e1e5e7e1b77e4cbece9ee962427f8b0c5c603c1cd1cdc091400ca8cf6e860dfd39a4
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.2.pre.pre1)
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
@@ -14,8 +14,6 @@ module Spree
14
14
  end
15
15
 
16
16
  def update_telegram_chat
17
- @vendor.update(preferred_telegram_alert_channels: submitted_alert_channels_mask)
18
-
19
17
  context = ::SpreeCmCommissioner::TelegramChatsFinder.call(
20
18
  telegram_chat_name: params['vendor']['preferred_telegram_chat_name'],
21
19
  telegram_chat_type: params['vendor']['preferred_telegram_chat_type']
@@ -36,19 +34,6 @@ module Spree
36
34
  redirect_to collection_url(@vendor)
37
35
  end
38
36
 
39
- # OR the bits, never sum them. Sum is not idempotent: if the same bit arrives twice
40
- # (duplicated checkbox, double-submit, hand-crafted post) 1 + 1 = 2, which silently means
41
- # google_form instead of spree. ["0","1","1","16"].sum => 18 => [google_form, offline_sell];
42
- # the same input OR'd => 17 => [spree, offline_sell].
43
- #
44
- # Masking against the known bits also drops junk from a crafted post (999 => 7).
45
- def submitted_alert_channels_mask
46
- known = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS.values.reduce(:|)
47
-
48
- Array(params.dig(:vendor, :telegram_alert_channels))
49
- .reduce(0) { |acc, value| acc | value.to_i } & known
50
- end
51
-
52
37
  # @overrided
53
38
  def model_class
54
39
  Spree::User
@@ -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
@@ -1,7 +1,7 @@
1
1
  # Make sure to put this concern below other concern or methods that generating additional order info like guests, seat number, etc.
2
2
  # This will ensure that when each notification is sent with neccessary data.
3
3
  module SpreeCmCommissioner
4
- module OrderStateMachine # rubocop:disable Metrics/ModuleLength
4
+ module OrderStateMachine
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do # rubocop:disable Metrics/BlockLength
@@ -271,30 +271,15 @@ module SpreeCmCommissioner
271
271
  return unless telegram_alert_enabled?
272
272
 
273
273
  vendor_list.each do |vendor|
274
- # `next`, not `return`: the mask is per-vendor, so one opted-out vendor on a
275
- # multi-vendor order must not suppress the others.
276
- next unless vendor.alerts_telegram_for_channel?(channel_key)
277
-
274
+ title = '🎫 --- [NEW ORDER FROM BOOKME+] ---'
278
275
  chat_id = vendor.preferred_telegram_chat_id
279
- next if chat_id.blank?
280
-
281
- factory = OrderTelegramMessageFactory.new(title: vendor_alert_title(vendor), order: self, vendor: vendor)
282
- TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
276
+ if chat_id.present?
277
+ factory = OrderTelegramMessageFactory.new(title: title, order: self, vendor: vendor)
278
+ TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
279
+ end
283
280
  end
284
281
  end
285
282
 
286
- # White-label vendors announce their own brand; platform vendors stay BookMe+.
287
- #
288
- # vendor.name is operator-supplied data interpolated into an HTML parse_mode message
289
- # (TelegramMessageFactory#header wraps the title in <b>), so it MUST be escaped —
290
- # Telegram rejects unescaped &, < and > with "400 can't parse entities", and
291
- # TelegramNotificationSender swallows that error, so the alert would vanish silently.
292
- def vendor_alert_title(vendor)
293
- return '🎫 --- [NEW ORDER FROM BOOKME+] ---' unless vendor.tenant_present?
294
-
295
- "🎫 --- [NEW ORDER FROM #{CGI.escapeHTML(vendor.name.to_s)}] ---"
296
- end
297
-
298
283
  def notify_order_complete_app_notification_to_user
299
284
  return if products.all?(&:vote_package?) # disable app push notification for vote package orders
300
285
 
@@ -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
 
@@ -5,11 +5,11 @@ module SpreeCmCommissioner
5
5
  included do
6
6
  has_one :ticket_transfer, class_name: 'SpreeCmCommissioner::TicketTransfer'
7
7
 
8
- scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL) }
8
+ scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL) }
9
9
  end
10
10
 
11
11
  def ticket_transfer?
12
- channel == SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL
12
+ channel == SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL
13
13
  end
14
14
 
15
15
  # Completes an accepted ticket transfer for this order. Called from
@@ -8,25 +8,6 @@ module SpreeCmCommissioner
8
8
  preference :telegram_chat_id, :string
9
9
  preference :telegram_chat_name, :string
10
10
  preference :telegram_chat_type, :string
11
- preference :telegram_alert_channels, :integer, default: 1
12
- end
13
-
14
- # Should this vendor's Telegram group be alerted for an order on this channel?
15
- # An unmapped channel alerts — a stray message is a better failure than silence.
16
- def alerts_telegram_for_channel?(channel_key)
17
- bit = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS[channel_key]
18
- return true if bit.nil?
19
-
20
- telegram_alert_channels_mask.anybits?(bit)
21
- end
22
-
23
- # Spree's getter is `preferences.fetch(name) { default }`, which falls back only when the
24
- # key is ABSENT. A key present with a nil value returns nil, and nil.to_i == 0 would mute
25
- # EVERY channel silently. Fall back to the default instead; a deliberate 0 survives
26
- # because 0 is truthy in Ruby.
27
- def telegram_alert_channels_mask
28
- (preferred_telegram_alert_channels ||
29
- SpreeCmCommissioner::OrderChannelBitwise::DEFAULT_TELEGRAM_ALERT_CHANNELS).to_i
30
11
  end
31
12
  end
32
13
  end
@@ -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
 
@@ -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.
@@ -1,7 +1,12 @@
1
1
  module SpreeCmCommissioner
2
2
  module OrderDecorator
3
+ SPREE_CHANNEL = 'spree'.freeze
4
+ GOOGLE_FORM_CHANNEL = 'google_form'.freeze
5
+ TELEGRAM_CHANNEL = 'telegram'.freeze
6
+ TICKET_TRANSFER_CHANNEL = 'ticket_transfer'.freeze
7
+ ALLOWED_CHANNEL_PREFIXES = [SPREE_CHANNEL, GOOGLE_FORM_CHANNEL, TELEGRAM_CHANNEL, TICKET_TRANSFER_CHANNEL].freeze
8
+
3
9
  def self.prepended(base) # rubocop:disable Metrics/MethodLength
4
- base.include SpreeCmCommissioner::OrderChannelBitwise
5
10
  base.include SpreeCmCommissioner::StoreMetadata
6
11
  base.include SpreeCmCommissioner::PhoneNumberSanitizer
7
12
  base.include SpreeCmCommissioner::OrderIntegration
@@ -22,6 +27,7 @@ module SpreeCmCommissioner
22
27
  base.store_public_metadata :order_options, :array, default: []
23
28
 
24
29
  base.validates :promo_total, base::MONEY_VALIDATION
30
+ base.validate :validate_channel_prefix, if: :channel_changed?
25
31
  base.validates :phone_number, presence: true, if: :require_phone_number
26
32
 
27
33
  base.has_one :invoice, dependent: :destroy, class_name: 'SpreeCmCommissioner::Invoice'
@@ -357,6 +363,12 @@ module SpreeCmCommissioner
357
363
  self.ship_address ||= customer.ship_address.try(:clone)
358
364
  end
359
365
 
366
+ def validate_channel_prefix
367
+ return if ALLOWED_CHANNEL_PREFIXES.any? { |prefix| channel&.start_with?(prefix) }
368
+
369
+ errors.add(:channel, "must start with one of the following: #{ALLOWED_CHANNEL_PREFIXES.join(', ')}")
370
+ end
371
+
360
372
  def set_tenant_id
361
373
  self.tenant_id = MultiTenant.current_tenant_id
362
374
  end
@@ -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?