spree_cm_commissioner 2.10.0 → 2.10.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/admin/vendors_controller_decorator.rb +7 -0
  4. data/app/errors/spree_cm_commissioner/reserved_blocks/locked_by_operator_error.rb +25 -0
  5. data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +2 -2
  6. data/app/jobs/spree_cm_commissioner/async_operation_job.rb +27 -0
  7. data/app/jobs/spree_cm_commissioner/ticket_transfers/expire_job.rb +29 -0
  8. data/app/jobs/spree_cm_commissioner/voting_session_auto_close_job.rb +22 -0
  9. data/app/jobs/spree_cm_commissioner/voting_session_firestore_publish_job.rb +18 -0
  10. data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +27 -0
  11. data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +11 -0
  12. data/app/models/concerns/spree_cm_commissioner/voting_session_config_concern.rb +87 -0
  13. data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +137 -0
  14. data/app/models/spree_cm_commissioner/async_operation.rb +73 -0
  15. data/app/models/spree_cm_commissioner/async_operations/seat_lock.rb +76 -0
  16. data/app/models/spree_cm_commissioner/block.rb +32 -2
  17. data/app/models/spree_cm_commissioner/inventory_item.rb +17 -1
  18. data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +6 -5
  19. data/app/models/spree_cm_commissioner/payment_decorator.rb +4 -14
  20. data/app/models/spree_cm_commissioner/promotion_decorator.rb +19 -0
  21. data/app/models/spree_cm_commissioner/reserved_block.rb +29 -1
  22. data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
  23. data/app/models/spree_cm_commissioner/ticket_transfer.rb +1 -1
  24. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -0
  25. data/app/models/spree_cm_commissioner/vendor_decorator.rb +10 -0
  26. data/app/models/spree_cm_commissioner/voting_contestant.rb +34 -1
  27. data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
  28. data/app/models/spree_cm_commissioner/voting_session.rb +44 -83
  29. data/app/overrides/spree/admin/vendors/_form/sellable_product_types.html.erb.deface +9 -0
  30. data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +4 -0
  31. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -1
  32. data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +6 -1
  33. data/app/services/spree_cm_commissioner/fraud_check.rb +2 -2
  34. data/app/services/spree_cm_commissioner/guests/update_seat.rb +15 -0
  35. data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +83 -0
  36. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +5 -1
  37. data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +19 -8
  38. data/app/services/spree_cm_commissioner/reserved_blocks/cancel.rb +5 -1
  39. data/app/services/spree_cm_commissioner/reserved_blocks/hold.rb +14 -3
  40. data/app/services/spree_cm_commissioner/reserved_blocks/reserve.rb +4 -0
  41. data/app/services/spree_cm_commissioner/seats/apply_locks.rb +105 -0
  42. data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +73 -0
  43. data/app/services/spree_cm_commissioner/seats/lock_blocks.rb +136 -0
  44. data/app/services/spree_cm_commissioner/seats/lock_state.rb +159 -0
  45. data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +71 -0
  46. data/app/services/spree_cm_commissioner/ticket_transfers/complete.rb +4 -4
  47. data/app/services/spree_cm_commissioner/ticket_transfers/expire.rb +7 -1
  48. data/app/services/spree_cm_commissioner/vote_counters/base.rb +3 -1
  49. data/app/services/spree_cm_commissioner/vote_counters/increment.rb +1 -1
  50. data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +8 -2
  51. data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +103 -0
  52. data/config/initializers/spree_permitted_attributes.rb +3 -0
  53. data/config/locales/en.yml +5 -0
  54. data/config/locales/km.yml +2 -0
  55. data/db/migrate/20260806000000_add_metadata_to_cm_reserved_blocks.rb +8 -0
  56. data/db/migrate/20260806000001_add_metadata_to_cm_inventory_items.rb +6 -0
  57. data/db/migrate/20260811000000_add_vote_window_to_cm_voting_sessions.rb +14 -0
  58. data/db/migrate/20260811000001_add_visible_to_cm_voting_contestants.rb +9 -0
  59. data/db/migrate/20260811000002_add_visible_to_cm_voting_sessions.rb +8 -0
  60. data/db/migrate/20260811000004_reorder_lifecycle_status_on_cm_voting_sessions.rb +40 -0
  61. data/db/migrate/20260812000000_create_cm_async_operations.rb +23 -0
  62. data/db/migrate/20260812000000_replace_timing_mode_with_vote_window_on_cm_voting_sessions.rb +21 -0
  63. data/db/migrate/20260812125617_add_metadata_to_spree_vendors.rb +6 -0
  64. data/lib/spree_cm_commissioner/test_helper/factories/async_operation_factory.rb +29 -0
  65. data/lib/spree_cm_commissioner/test_helper/factories/reserved_block_factory.rb +8 -0
  66. data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +14 -0
  67. data/lib/spree_cm_commissioner/version.rb +1 -1
  68. metadata +30 -3
  69. data/app/jobs/spree_cm_commissioner/expire_ticket_transfer_job.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d59a44e1e25ec19ab8c634622f91006ea28bec265ea877dad8565cffa54bad3
4
- data.tar.gz: 43415e8984f459f0387e110707963f4685c92251f589a2301342f82783c4e714
3
+ metadata.gz: 34524289f2fc489da23a30fcaca4bfcdac167a60b0efd382368efda96433e4d9
4
+ data.tar.gz: 47cd7bcb029126c27c0dd1c7081f26b8a217e740be768662f59a208a0e440ad6
5
5
  SHA512:
6
- metadata.gz: 1fbfc67d4bc18eb071d854b643ef3bfbaf1312b75f5c241020d58b09855ee234a58064ce08a104ee856b48dec25ce8547ad9c442cbd2648c83ad350c04fec3e1
7
- data.tar.gz: 53317e748ac053ed6b60ec7e9accdd915266aa6b3e547692865191868d6051693dcad05dee73bed5e196b027fc27436e72fba2c62e05b3b33f806a7558618cf9
6
+ metadata.gz: 134f803e39f5fce95401a2404cf6f559baf6997651440578767022cc9c7c4ddf1368da4b16ec12b096753569794593a03a8ace0ac1522fab107a0ae57b09db04
7
+ data.tar.gz: d8401ff0a245a5429919380ffc56412e2a09614fe82a78bef24f2e90ff54d88feb885ea0bb78993d2f45e9e7cc6a294958642290e53f8ea41650fc3ca4652759
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.10.0)
37
+ spree_cm_commissioner (2.10.1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -5,10 +5,17 @@ module Spree
5
5
  base.before_action :build_logo, only: %i[create update]
6
6
  base.before_action :build_payment_qrcode, only: %i[create update]
7
7
  base.before_action :contruct_nearby_place, only: %i[create update]
8
+ base.before_action :compact_sellable_product_types, only: %i[create update]
8
9
  end
9
10
 
10
11
  private
11
12
 
13
+ def compact_sellable_product_types
14
+ return unless permitted_resource_params.key?(:sellable_product_types)
15
+
16
+ permitted_resource_params[:sellable_product_types] = permitted_resource_params[:sellable_product_types].compact_blank
17
+ end
18
+
12
19
  def contruct_nearby_place
13
20
  return unless permitted_resource_params.key?(:nearby_places_attributes)
14
21
 
@@ -0,0 +1,25 @@
1
+ module SpreeCmCommissioner
2
+ module ReservedBlocks
3
+ # Raised when a customer tries to hold or reserve a seat an operator has taken off sale.
4
+ #
5
+ # The customer-facing message deliberately says only "not available" — it must not leak the
6
+ # operator's reason (e.g. "held for staff"), which lives in private_metadata.
7
+ class LockedByOperatorError < StandardError
8
+ attr_reader :block_label
9
+
10
+ def initialize(block_label = nil)
11
+ @block_label = block_label
12
+ super()
13
+ end
14
+
15
+ # override
16
+ def message
17
+ if block_label.present?
18
+ I18n.t('line_item.validation.blocks_are_locked_by_operator_with_label', label: block_label)
19
+ else
20
+ I18n.t('line_item.validation.blocks_are_locked_by_operator')
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -15,8 +15,8 @@ module SpreeCmCommissioner
15
15
  .joins(:inventory_items)
16
16
  .where(vendor_id: vendor_id)
17
17
  .where(inventory_items: { inventory_date: stay_dates })
18
- .where('CAST(public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
19
- CAST(public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
18
+ .where('CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
19
+ CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
20
20
  )
21
21
  .where('inventory_items.quantity_available > 0')
22
22
  .distinct
@@ -0,0 +1,27 @@
1
+ module SpreeCmCommissioner
2
+ # Runs any AsyncOperation subclass. Same shape as ExportJob: load the record, call the one method the
3
+ # subclass implements.
4
+ #
5
+ # ApplicationJob, not ApplicationUniqueJob: `unique :until_executed` keys on the job arguments, and
6
+ # those carry a per-record id, so uniqueness could never collide -- it would buy a Redis lock for
7
+ # nothing. The in_progress? guard below is what actually makes a duplicate enqueue a no-op.
8
+ class AsyncOperationJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ def perform(options = {})
12
+ operation = SpreeCmCommissioner::AsyncOperation.find_by(id: options[:async_operation_id])
13
+ return if operation.blank?
14
+
15
+ # Guard on `done` only, not on "not in progress". An exception below marks the operation failed and
16
+ # re-raises so Sidekiq retries -- guarding on in_progress? would make every one of those retries
17
+ # return here immediately, which is the opposite of retrying. Completed work is the thing that must
18
+ # never run twice; a failed operation is exactly what a retry is for.
19
+ return if operation.done?
20
+
21
+ operation.process!
22
+ rescue StandardError => e
23
+ operation&.fail!(e.message)
24
+ raise # ApplicationJob logs it; Sidekiq retries; Sentry captures.
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module SpreeCmCommissioner
2
+ module TicketTransfers
3
+ class ExpireJob < ApplicationJob
4
+ # Same window as OrderHolds::LockForPayment — gives an in-flight gateway
5
+ # redirect/webhook (e.g. PayWay, ACLEDA) room to resolve before we retry.
6
+ RETRY_DELAY = ENV.fetch('PAYMENT_LOCK_MIN_DURATION_IN_MINUTES', '5').to_i.minutes
7
+ MAX_RETRIES = 1
8
+ UNRESOLVED_PAYMENT_STATES = %w[checkout pending processing].freeze
9
+
10
+ def perform(transfer_id, attempt = 0)
11
+ ticket_transfer = SpreeCmCommissioner::TicketTransfer.find_by(id: transfer_id)
12
+ return unless ticket_transfer
13
+
14
+ if payment_in_progress?(ticket_transfer) && attempt < MAX_RETRIES
15
+ self.class.set(wait: RETRY_DELAY).perform_later(transfer_id, attempt + 1)
16
+ return
17
+ end
18
+
19
+ SpreeCmCommissioner::TicketTransfers::Expire.call(ticket_transfer: ticket_transfer)
20
+ end
21
+
22
+ private
23
+
24
+ def payment_in_progress?(ticket_transfer)
25
+ ticket_transfer.order&.payments&.where(state: UNRESOLVED_PAYMENT_STATES)&.exists? || false
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ module SpreeCmCommissioner
2
+ # Flips the Firestore `state` to closed once a manually-started vote window elapses.
3
+ #
4
+ # Enqueued by VotingSessionTimingConcern#start_vote! with wait_until: vote_close_at. Vote
5
+ # rejection does not depend on this job — can_vote? already gates on the persisted
6
+ # vote_close_at — so a missed run costs a stale label in the app, never an accepted late vote.
7
+ class VotingSessionAutoCloseJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(voting_session_id)
11
+ voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
12
+ return if voting_session.nil?
13
+
14
+ # vote_close_at may have moved since this was scheduled (a restart, or the organizer
15
+ # pressing Stop Vote and starting a new window). Only publish once the window has genuinely
16
+ # elapsed; otherwise the run that matches the new vote_close_at will handle it.
17
+ return if voting_session.vote_close_at.present? && Time.current < voting_session.vote_close_at
18
+
19
+ SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module SpreeCmCommissioner
2
+ # Pushes a voting session's live state to Firestore off the request cycle, so an organizer
3
+ # pressing "Start Vote" never waits on a Firestore round-trip.
4
+ #
5
+ # Takes an id rather than the record so a retry publishes the state as it is *now*, not as it
6
+ # was when the job was enqueued — a rapid start/pause/stop sequence must not have a stale
7
+ # retry overwrite the current state.
8
+ class VotingSessionFirestorePublishJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ def perform(voting_session_id)
12
+ voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
13
+ return if voting_session.nil?
14
+
15
+ SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ module SpreeCmCommissioner
4
+ # Shared Cloud Firestore client construction.
5
+ #
6
+ # The waiting-room services each inline this same pair (see WaitingGuestsCaller#firestore);
7
+ # this concern is the extracted version for new writers, plus a #firestore_available? guard so
8
+ # environments without the credential (dev, test, CI) no-op instead of raising on connect.
9
+ module FirestoreConnection
10
+ extend ActiveSupport::Concern
11
+
12
+ def firestore_available?
13
+ service_account.present?
14
+ end
15
+
16
+ def firestore
17
+ @firestore ||= Google::Cloud::Firestore.new(
18
+ project_id: service_account[:project_id],
19
+ credentials: service_account
20
+ )
21
+ end
22
+
23
+ def service_account
24
+ @service_account ||= Rails.application.credentials.cloud_firestore_service_account
25
+ end
26
+ end
27
+ end
@@ -11,5 +11,16 @@ module SpreeCmCommissioner
11
11
  def ticket_transfer?
12
12
  channel == SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL
13
13
  end
14
+
15
+ # Completes an accepted ticket transfer for this order. Called from
16
+ # Spree::Payment#after_completed, so the buyer's payment is already captured.
17
+ def complete_ticket_transfer_if_ready
18
+ return unless ticket_transfer?
19
+
20
+ transfer = ticket_transfer
21
+ return if transfer.blank? || !transfer.accepted?
22
+
23
+ SpreeCmCommissioner::TicketTransfers::Complete.call(ticket_transfer: transfer)
24
+ end
14
25
  end
15
26
  end
@@ -0,0 +1,87 @@
1
+ module SpreeCmCommissioner
2
+ # The fraud_config/voting_config JSON stores and their derived, show-inheriting accessors.
3
+ # Extracted purely to keep VotingSession under RuboCop's Metrics/ClassLength — no behavior
4
+ # here depends on anything else in the concern split (unlike VotingSessionTimingConcern, whose
5
+ # enum/predicate ordering matters).
6
+ module VotingSessionConfigConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ store_accessor :fraud_config,
11
+ :max_votes_per_minute_per_user,
12
+ :max_votes_per_minute_per_ip,
13
+ :max_accounts_per_device,
14
+ :block_vpn,
15
+ :max_votes_per_contestant_per_user
16
+
17
+ store_accessor :voting_config,
18
+ :free_vote_limit,
19
+ :free_vote_limit_type,
20
+ :allow_user_to_get_free_vote,
21
+ :paid_vote_enabled,
22
+ :max_paid_votes_per_session,
23
+ :sms_vote_enabled,
24
+ :sms_vote_cost_usd,
25
+ :operator_vote_enabled,
26
+ :votes_weight,
27
+ :voters_weight,
28
+ :suggestion_top_n,
29
+ :pre_vote_weight
30
+
31
+ validate :weights_present_for_weighted_sum
32
+ end
33
+
34
+ DEFAULT_SUGGESTION_TOP_N = 1
35
+
36
+ def effective_suggestion_top_n
37
+ suggestion_top_n&.to_i&.positive? ? suggestion_top_n.to_i : DEFAULT_SUGGESTION_TOP_N
38
+ end
39
+
40
+ def effective_voting_config
41
+ return {} unless show
42
+
43
+ config = parse_json_config(voting_config)
44
+ show_config = show.effective_voting_config
45
+ config.reverse_merge(show_config || {})
46
+ end
47
+
48
+ def effective_fraud_config
49
+ return {} unless show
50
+
51
+ config = parse_json_config(fraud_config)
52
+ show_config = show.effective_fraud_config
53
+ config.reverse_merge(show_config || {})
54
+ end
55
+
56
+ def max_votes_per_user
57
+ effective_voting_config['max_votes_per_user'] || 10
58
+ end
59
+
60
+ def rate_limit_window
61
+ effective_voting_config['rate_limit_window'] || 60
62
+ end
63
+
64
+ def rate_limit_max_votes
65
+ effective_voting_config['rate_limit_max_votes'] || 5
66
+ end
67
+
68
+ private
69
+
70
+ def weights_present_for_weighted_sum
71
+ return unless weighted_sum?
72
+
73
+ config = effective_voting_config
74
+ errors.add(:votes_weight, :blank) if config['votes_weight'].blank?
75
+ errors.add(:voters_weight, :blank) if config['voters_weight'].blank?
76
+ end
77
+
78
+ def parse_json_config(value)
79
+ return {} if value.nil? || value == ''
80
+ return value if value.is_a?(Hash)
81
+
82
+ JSON.parse(value)
83
+ rescue JSON::ParserError
84
+ {}
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,137 @@
1
+ module SpreeCmCommissioner
2
+ # How a voting session's vote window (vote_open_at/vote_close_at) gets its values, kept
3
+ # deliberately separate from opens_at/closes_at — the show's own time slot within its episode
4
+ # (an episode can span many voting sessions, e.g. one per day of a week-long run), which has
5
+ # nothing to do with when voting is actually open.
6
+ #
7
+ # There is no mode flag: the presence of vote_open_at/vote_close_at is the signal.
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.
11
+ # Left blank — #start_vote! rewrites them to now / now + vote_duration_seconds the
12
+ # moment the organizer presses it, live during the broadcast.
13
+ #
14
+ # Both paths start at status: archived and reach enabled through one of two routes:
15
+ #
16
+ # archived --(check "Visible")--> on_air --#start_vote!--> enabled (progressive reveal, then vote)
17
+ # archived ------------------------------#start_vote!--> enabled (reveal and vote in one click)
18
+ #
19
+ # There is no separate "reveal" action: setting visible: true while archived (from the organizer
20
+ # dashboard's edit form) *is* the reveal — see #promote_from_archived_when_made_visible, which
21
+ # auto-transitions status to on_air in the same write rather than rejecting the attempt. on_air
22
+ # exists specifically so a session can show its contestants one by one — in sync with a live
23
+ # performance — before voting opens; it is visible but never votable (status isn't enabled, so
24
+ # can_vote? is false regardless of vote_open_at).
25
+ module VotingSessionTimingConcern
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ validates :vote_duration_seconds, numericality: { greater_than: 0 }, allow_nil: true
30
+ validate :vote_closes_after_vote_opens
31
+
32
+ # before_update, not before_save: the `visible` column defaults to true at the DB level, so
33
+ # a brand-new record saved with the default status: archived (see
34
+ # ParamsConcern#force_archived_status_on_create, which explicitly sets visible: false too)
35
+ # must never auto-promote itself on its first save just because it hasn't touched `visible`
36
+ # yet — "reveal" is something an organizer does to an *existing* archived session.
37
+ before_update :promote_from_archived_when_made_visible
38
+ after_commit :publish_live_state_to_firestore, on: %i[create update], if: :live_state_changed?
39
+
40
+ scope :visible, -> { where(visible: true) }
41
+ end
42
+
43
+ # Columns the show screen renders. `display` is included because hiding a session should
44
+ # disappear from the app as promptly as opening one appears.
45
+ LIVE_STATE_COLUMNS = %w[status opens_at closes_at vote_open_at vote_close_at display visible].freeze
46
+
47
+ # Opens the vote window *now* rather than at a pre-set time, overriding whatever vote_open_at
48
+ # already held (a live show running early is exactly when this gets pressed). visible: true is
49
+ # set explicitly so this also works when called directly from archived — revealing and opening
50
+ # voting in one click, without going through on_air first.
51
+ def start_vote!
52
+ now = Time.current
53
+ update!(
54
+ vote_open_at: now,
55
+ vote_close_at: now + vote_duration_seconds.to_i.seconds,
56
+ status: :enabled,
57
+ visible: true
58
+ )
59
+
60
+ SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
61
+ end
62
+
63
+ # Ends the vote window immediately and moves to closed. status: closed (not just a rewritten
64
+ # vote_close_at) matters for two reasons: votes are rejected the instant this runs, since
65
+ # can_vote? short-circuits on enabled? before ever comparing timestamps — no dependency on the
66
+ # inclusive Time#between? boundary at vote_close_at; and it's what triggers
67
+ # schedule_counter_reconciliation (only fires on saved_change_to_status?(enabled -> closed)),
68
+ # which would otherwise only run via the separate "Close" action, never on a manual stop.
69
+ #
70
+ # vote_close_at clamped to vote_open_at + 1s: an organizer who starts and immediately stops
71
+ # would otherwise produce vote_close_at == vote_open_at, which the closes-after-opens
72
+ # validation rejects — a validation error on what is a legitimate "cancel that" action.
73
+ def stop_vote!
74
+ opened_at = vote_open_at || Time.current
75
+ update!(status: :closed, vote_close_at: [Time.current, opened_at + 1.second].max)
76
+ end
77
+
78
+ # True when voting hasn't opened yet — either vote_open_at was never set, it's set but its
79
+ # time hasn't arrived, or status is on_air. The on_air check matters on its own: on_air can be
80
+ # revisited after a prior round already closed (an organizer reverting status by hand to
81
+ # re-reveal), in which case vote_open_at/vote_close_at are still populated from that earlier
82
+ # round and both in the past — without this, the timestamp check alone would say the window
83
+ # isn't pending, and Firestore's state_for would wrongly report 'closed' for a session that's
84
+ # actually on_air right now. Covers "never started," "pre-scheduled but not live yet," and
85
+ # "on_air again after a previous round" identically, since none of them is votable nor has a
86
+ # real countdown to show.
87
+ def vote_window_pending?
88
+ on_air? || vote_open_at.blank? || Time.current < vote_open_at
89
+ end
90
+
91
+ # Shifts vote_close_at by `seconds` (organizer-chosen at the moment, not necessarily
92
+ # vote_duration_seconds) without touching vote_open_at — distinct from #start_vote!, which
93
+ # would discard the time already elapsed by resetting the window to start now.
94
+ #
95
+ # Extends from max(vote_close_at, Time.current), not from the raw stored value: the
96
+ # controller also offers this once a window has already lapsed (status still enabled, nothing
97
+ # auto-flipped it to closed yet) specifically to reopen it. Adding straight to a stale
98
+ # vote_close_at sitting hours in the past would land the new value in the past too — "+10
99
+ # minutes" on a session that closed last night must mean 10 minutes from now, not 10 minutes
100
+ # from when it closed.
101
+ #
102
+ # `seconds` may be negative to cut a currently-open window short without a full Stop Vote;
103
+ # going negative enough to land at or before vote_open_at raises via the closes-after-opens
104
+ # validation.
105
+ def extend_vote!(seconds)
106
+ 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
+ end
110
+
111
+ private
112
+
113
+ # archived sessions are prepped privately by default (visible starts false), but the organizer
114
+ # controls visibility directly from the edit form — checking "Visible to Viewers" while
115
+ # archived is the reveal action, not a separate button. Rather than rejecting that write, treat
116
+ # it as intent: promote status to on_air in the same save, so the session actually becomes
117
+ # reachable via the `current` scope (which never includes plain `archived`, regardless of
118
+ # `visible`). Setting visible: false while archived is a no-op — status simply stays archived.
119
+ def promote_from_archived_when_made_visible
120
+ self.status = 'on_air' if archived? && visible?
121
+ end
122
+
123
+ def vote_closes_after_vote_opens
124
+ return unless vote_open_at && vote_close_at
125
+
126
+ errors.add(:vote_close_at, :must_be_after_vote_opens_at) if vote_close_at <= vote_open_at
127
+ end
128
+
129
+ def live_state_changed?
130
+ LIVE_STATE_COLUMNS.any? { |column| saved_change_to_attribute?(column) }
131
+ end
132
+
133
+ def publish_live_state_to_firestore
134
+ SpreeCmCommissioner::VotingSessionFirestorePublishJob.perform_later(id)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,73 @@
1
+ module SpreeCmCommissioner
2
+ # A long-running operation a human started and is waiting on the result of, with a status an admin
3
+ # screen can poll: queue -> progress -> done | failed.
4
+ #
5
+ # SCOPE RULE: operations a person initiated and is watching. Not webhook deliveries, not per-order
6
+ # background work, not general job logging.
7
+ #
8
+ # That rule is what keeps this table small enough to need no retention policy -- admin actions are rare,
9
+ # and the rows are a few hundred bytes of jsonb. cm_exports, the table this is modelled on, has grown
10
+ # unpruned while storing actual files. If a future subclass ever puts high-volume work here, add a
11
+ # purge alongside cleanup_integration_sync_sessions in config/schedule.yml; the
12
+ # [type, status, created_at] index exists to serve exactly that query.
13
+ #
14
+ # Subclasses supply their own payload/result keys via store_*_metadata and implement #process!.
15
+ # See AsyncOperations::SeatLock.
16
+ class AsyncOperation < Base
17
+ include SpreeCmCommissioner::StoreMetadata
18
+
19
+ belongs_to :operable, polymorphic: true, optional: true
20
+ belongs_to :user, optional: true, class_name: 'Spree::User'
21
+
22
+ enum status: { queue: 0, progress: 1, done: 2, failed: 3 }
23
+
24
+ # :text, not :string. StoreMetadata attaches `length: { maximum: 255 }` to every :string key, so a
25
+ # long exception message would fail validation inside the failure handler and bury the real error.
26
+ store_private_metadata :error, :text
27
+
28
+ ERROR_LIMIT = 1000
29
+
30
+ # after_create_commit, not after_create: Sidekiq can dequeue faster than the enclosing transaction
31
+ # commits, and the job would then find no row and silently drop the operation.
32
+ after_create_commit :enqueue
33
+
34
+ # Subclasses may override to pick a different queue or a dedicated job.
35
+ def enqueue
36
+ SpreeCmCommissioner::AsyncOperationJob.perform_later(async_operation_id: id)
37
+ end
38
+
39
+ def in_progress?
40
+ queue? || progress?
41
+ end
42
+
43
+ # Heartbeat, not wall clock. A running operation touches the row as it accumulates results, so real
44
+ # work never looks stale while a killed worker does. Without this the admin panel spins forever.
45
+ def stale?
46
+ in_progress? && updated_at < timeout.ago
47
+ end
48
+
49
+ def timeout
50
+ 20.minutes
51
+ end
52
+
53
+ def start!
54
+ update!(status: :progress, started_at: Time.current)
55
+ end
56
+
57
+ def finish!
58
+ update!(status: :done, finished_at: Time.current)
59
+ end
60
+
61
+ # save(validate: false), not update!: this runs from a rescue block, so a subclass validation failing
62
+ # here would raise a second exception and bury the original. Recording a failure must not be able to
63
+ # fail.
64
+ def fail!(message)
65
+ assign_attributes(status: :failed, finished_at: Time.current, error: message.to_s.truncate(ERROR_LIMIT))
66
+ save(validate: false)
67
+ end
68
+
69
+ def process!
70
+ raise NotImplementedError, 'Subclasses must implement #process!'
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,76 @@
1
+ module SpreeCmCommissioner
2
+ module AsyncOperations
3
+ # One operator submission of the "Lock Seats" drawer.
4
+ #
5
+ # Stores the DESIRED state (which seats should end up locked across which dates), not a diff. The diff
6
+ # against what is currently locked is computed by Seats::ApplyLocks at perform time, so it works from
7
+ # fresh data rather than a snapshot taken when the drawer was rendered.
8
+ class SeatLock < SpreeCmCommissioner::AsyncOperation
9
+ # Everything private: why a seat is off sale is operator-internal, same rule as ReservedBlock.
10
+ store_private_metadata :block_ids, :array, default: []
11
+ store_private_metadata :selections, :array, default: [] # ["512:2026-08-08", ...]
12
+ store_private_metadata :lock_reason, :string
13
+ store_private_metadata :lock_note, :text
14
+
15
+ store_private_metadata :locked_count, :integer, default: 0
16
+ store_private_metadata :unlocked_count, :integer, default: 0
17
+ store_private_metadata :skipped_summary, :hash, default: {} # { reason => count }
18
+ store_private_metadata :skipped_samples, :array, default: [] # [{ date:, block_label:, reason: }]
19
+
20
+ # Skips are summarised rather than listed. each_block_date emits a :no_inventory skip for every pair
21
+ # with no inventory item, so an ordinary action -- 31 dates selected where only 10 have inventory,
22
+ # 40 seats -- produces 840 entries. A reason->count hash plus a small sample is both a row that
23
+ # stays small and the only version an operator can actually read.
24
+ SKIPPED_SAMPLE_LIMIT = 20
25
+
26
+ alias_attribute :trip, :operable
27
+
28
+ def process!
29
+ SpreeCmCommissioner::Seats::ApplyLocks.call!(operation: self)
30
+ end
31
+
32
+ def skipped_count
33
+ skipped_summary.values.sum(&:to_i)
34
+ end
35
+
36
+ def any_skips?
37
+ skipped_count.positive?
38
+ end
39
+
40
+ # One UPDATE per service call rather than per seat-date. Doubles as the stale? heartbeat, so a long
41
+ # run never looks like a dead worker.
42
+ #
43
+ # Reassigns every jsonb-backed value: ActiveRecord::Store does not dirty-track in-place mutation,
44
+ # so `skipped_samples << x` would be silently lost on save.
45
+ def track!(locked: 0, unlocked: 0, skips: [])
46
+ # Stringify once, on the way in. The services hand over symbol-keyed hashes but jsonb round-trips
47
+ # them to string keys, so without this the same record reads differently before and after a
48
+ # reload -- and every consumer would have to check both.
49
+ entries = skips.map { |skip| skip.transform_keys(&:to_s) }
50
+
51
+ self.locked_count = locked_count + locked
52
+ self.unlocked_count = unlocked_count + unlocked
53
+ self.skipped_summary = merge_skipped_summary(entries)
54
+ self.skipped_samples = (skipped_samples + sample_room(entries)).first(SKIPPED_SAMPLE_LIMIT)
55
+
56
+ save!
57
+ end
58
+
59
+ private
60
+
61
+ def merge_skipped_summary(entries)
62
+ entries.each_with_object(skipped_summary.dup) do |skip, acc|
63
+ reason = skip['reason'].to_s
64
+ acc[reason] = acc[reason].to_i + 1
65
+ end
66
+ end
67
+
68
+ def sample_room(entries)
69
+ room = SKIPPED_SAMPLE_LIMIT - skipped_samples.size
70
+ return [] if room <= 0
71
+
72
+ entries.first(room)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -28,6 +28,10 @@ module SpreeCmCommissioner
28
28
  validate :cannot_unassign_variant_with_active_blocks
29
29
 
30
30
  before_validation :assign_layout_from_section, if: -> { seat_layout.nil? && seat_section.present? }
31
+ # `prepend: true` is essential. `has_many :all_reserved_blocks, dependent: :destroy` registers its
32
+ # own before_destroy at association-declaration time, which is above this line — so without
33
+ # prepending, the reserved blocks are destroyed first and this guard finds nothing left to object to.
34
+ before_destroy :cannot_destroy_with_locked_blocks, prepend: true
31
35
 
32
36
  def label_required?
33
37
  sellable? || %w[other text].include?(block_type.to_s)
@@ -55,18 +59,44 @@ module SpreeCmCommissioner
55
59
 
56
60
  private
57
61
 
62
+ # Deleting a seat would cascade into its locked rows, dropping the deduction without giving the
63
+ # quantity back. Unlock first.
64
+ #
65
+ # Only `locked` needs this: reserved/on_hold rows have a guest, and the cm_guests -> cm_blocks
66
+ # foreign key already refuses. Locked rows have no guest, so nothing below the model stops them.
67
+ #
68
+ # `throw :abort` because errors.add alone does not halt a destroy.
69
+ def cannot_destroy_with_locked_blocks
70
+ count = all_reserved_blocks.locked.count
71
+ return if count.zero?
72
+
73
+ errors.add(
74
+ :base,
75
+ "cannot be deleted because #{count} #{count == 1 ? 'seat is' : 'seats are'} locked by the operator"
76
+ )
77
+ throw :abort
78
+ end
79
+
58
80
  def cannot_unassign_variant_with_active_blocks
59
81
  return if variant_id_was.blank? || variant_id.present?
60
82
 
61
- active_blocks = all_reserved_blocks.reserved_or_on_hold
83
+ # `unavailable` rather than `reserved_or_on_hold` so a variant carrying operator-locked seats
84
+ # cannot be unassigned either.
85
+ active_blocks = all_reserved_blocks.unavailable
62
86
  return unless active_blocks.exists?
63
87
 
64
88
  block_count = active_blocks.count
65
89
  block_statuses = active_blocks.pluck(:status).uniq.map { |s| s.humanize.downcase }
66
90
 
91
+ # "by guests" only when every blocker actually has one. `unavailable` includes `locked` rows,
92
+ # which belong to no guest, so keeping the suffix unconditionally would misdescribe the reason
93
+ # whenever an operator lock is among the blockers (on_hold/reserved still get "by guests").
94
+ by_guests = active_blocks.where.not(guest_id: nil).count == block_count
95
+
67
96
  errors.add(
68
97
  :variant,
69
- "cannot be unassigned because #{block_count} #{block_count == 1 ? 'block is' : 'blocks are'} #{block_statuses.join(' and ')} by guests"
98
+ "cannot be unassigned because #{block_count} #{block_count == 1 ? 'block is' : 'blocks are'} " \
99
+ "#{block_statuses.join(' and ')}#{' by guests' if by_guests}"
70
100
  )
71
101
  end
72
102
  end