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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/vendors_controller_decorator.rb +7 -0
- data/app/errors/spree_cm_commissioner/reserved_blocks/locked_by_operator_error.rb +25 -0
- data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +2 -2
- data/app/jobs/spree_cm_commissioner/async_operation_job.rb +27 -0
- data/app/jobs/spree_cm_commissioner/ticket_transfers/expire_job.rb +29 -0
- data/app/jobs/spree_cm_commissioner/voting_session_auto_close_job.rb +22 -0
- data/app/jobs/spree_cm_commissioner/voting_session_firestore_publish_job.rb +18 -0
- data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +27 -0
- data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +11 -0
- data/app/models/concerns/spree_cm_commissioner/voting_session_config_concern.rb +87 -0
- data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +137 -0
- data/app/models/spree_cm_commissioner/async_operation.rb +73 -0
- data/app/models/spree_cm_commissioner/async_operations/seat_lock.rb +76 -0
- data/app/models/spree_cm_commissioner/block.rb +32 -2
- data/app/models/spree_cm_commissioner/inventory_item.rb +17 -1
- data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +6 -5
- data/app/models/spree_cm_commissioner/payment_decorator.rb +4 -14
- data/app/models/spree_cm_commissioner/promotion_decorator.rb +19 -0
- data/app/models/spree_cm_commissioner/reserved_block.rb +29 -1
- data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
- data/app/models/spree_cm_commissioner/ticket_transfer.rb +1 -1
- data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -0
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +10 -0
- data/app/models/spree_cm_commissioner/voting_contestant.rb +34 -1
- data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
- data/app/models/spree_cm_commissioner/voting_session.rb +44 -83
- data/app/overrides/spree/admin/vendors/_form/sellable_product_types.html.erb.deface +9 -0
- data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +4 -0
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -1
- data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +6 -1
- data/app/services/spree_cm_commissioner/fraud_check.rb +2 -2
- data/app/services/spree_cm_commissioner/guests/update_seat.rb +15 -0
- data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +83 -0
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +5 -1
- data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +19 -8
- data/app/services/spree_cm_commissioner/reserved_blocks/cancel.rb +5 -1
- data/app/services/spree_cm_commissioner/reserved_blocks/hold.rb +14 -3
- data/app/services/spree_cm_commissioner/reserved_blocks/reserve.rb +4 -0
- data/app/services/spree_cm_commissioner/seats/apply_locks.rb +105 -0
- data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +73 -0
- data/app/services/spree_cm_commissioner/seats/lock_blocks.rb +136 -0
- data/app/services/spree_cm_commissioner/seats/lock_state.rb +159 -0
- data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +71 -0
- data/app/services/spree_cm_commissioner/ticket_transfers/complete.rb +4 -4
- data/app/services/spree_cm_commissioner/ticket_transfers/expire.rb +7 -1
- data/app/services/spree_cm_commissioner/vote_counters/base.rb +3 -1
- data/app/services/spree_cm_commissioner/vote_counters/increment.rb +1 -1
- data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +8 -2
- data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +103 -0
- data/config/initializers/spree_permitted_attributes.rb +3 -0
- data/config/locales/en.yml +5 -0
- data/config/locales/km.yml +2 -0
- data/db/migrate/20260806000000_add_metadata_to_cm_reserved_blocks.rb +8 -0
- data/db/migrate/20260806000001_add_metadata_to_cm_inventory_items.rb +6 -0
- data/db/migrate/20260811000000_add_vote_window_to_cm_voting_sessions.rb +14 -0
- data/db/migrate/20260811000001_add_visible_to_cm_voting_contestants.rb +9 -0
- data/db/migrate/20260811000002_add_visible_to_cm_voting_sessions.rb +8 -0
- data/db/migrate/20260811000004_reorder_lifecycle_status_on_cm_voting_sessions.rb +40 -0
- data/db/migrate/20260812000000_create_cm_async_operations.rb +23 -0
- data/db/migrate/20260812000000_replace_timing_mode_with_vote_window_on_cm_voting_sessions.rb +21 -0
- data/db/migrate/20260812125617_add_metadata_to_spree_vendors.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/async_operation_factory.rb +29 -0
- data/lib/spree_cm_commissioner/test_helper/factories/reserved_block_factory.rb +8 -0
- data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +14 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +30 -3
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34524289f2fc489da23a30fcaca4bfcdac167a60b0efd382368efda96433e4d9
|
|
4
|
+
data.tar.gz: 47cd7bcb029126c27c0dd1c7081f26b8a217e740be768662f59a208a0e440ad6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 134f803e39f5fce95401a2404cf6f559baf6997651440578767022cc9c7c4ddf1368da4b16ec12b096753569794593a03a8ace0ac1522fab107a0ae57b09db04
|
|
7
|
+
data.tar.gz: d8401ff0a245a5429919380ffc56412e2a09614fe82a78bef24f2e90ff54d88feb885ea0bb78993d2f45e9e7cc6a294958642290e53f8ea41650fc3ca4652759
|
data/Gemfile.lock
CHANGED
|
@@ -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
|
-
|
|
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'}
|
|
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
|