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