spree_cm_commissioner 2.8.3.pre.pre12 → 2.8.3.pre.pre14

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc7986faabd73e3ae1e70e3a80e14f59119301779f847a112d585d15bf52406f
4
- data.tar.gz: 97c8e0f4d601a7ddb553e786bc23f0f8b9a70a1eae307d7cf79da8e05d70d5c9
3
+ metadata.gz: e14575f7ac52d9cfa8c454de179107ec31f080a9b8bdc2ff1bfdebeeb5a42f46
4
+ data.tar.gz: fde706a204a681e6c94931be9dfc915ed5090f3abd81d2481120d598b1e168dd
5
5
  SHA512:
6
- metadata.gz: a1bec82d194a2943c597dc804c2a3f69c7a3f09da8c9734d82e76c1af7c102748175307e1a530a7107a0011bf5d1556cefc1c2d9fd7929e1e52ea2979912ed2c
7
- data.tar.gz: 992f9e6d0c5b58e06d726ed5967121d85530bf654410a475ce7b694f7e559dc42d6bc0e021136e550b000517b6d9a505e45db16bede3bc2ecc0b70c308c267ec
6
+ metadata.gz: c69e06e635df7a5259a6fbcab7d1fba55b93eac89ed5c12592e0070baf1bfbaa3ad442754604fd7cffe435d069c340a0f6b427abf39049d5fa5855b5a68fc64f
7
+ data.tar.gz: 33bf29761d4ee458f559c067c5872db8e381c0f9de7805ce02efaa76bde07229e97a5c20f42ba81a77eb585820dc4360eadbcd035cb4be740364ff1d2d947440
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.8.3.pre.pre12)
37
+ spree_cm_commissioner (2.8.3.pre.pre14)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -4,7 +4,10 @@ module Spree
4
4
  class WaitingRoomController < Spree::Admin::BaseController
5
5
  def show
6
6
  @fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new
7
+ @lobby_fetcher = SpreeCmCommissioner::WaitingRoomLobbyMetadataFetcher.new
8
+
7
9
  @fetcher.load_document_data
10
+ @lobby_fetcher.load_document_data
8
11
 
9
12
  @active_sesions_count = SpreeCmCommissioner::WaitingRoomSession.active.count
10
13
  end
@@ -29,6 +32,18 @@ module Spree
29
32
 
30
33
  redirect_back fallback_location: admin_system_waiting_room_path
31
34
  end
35
+
36
+ def publish_lobby_path
37
+ result = SpreeCmCommissioner::WaitingRoom::PublishLobbyPath.call
38
+
39
+ if result.success?
40
+ flash[:success] = "Published waiting guests records path: #{result.value[:records_path]}"
41
+ else
42
+ flash[:error] = result.error.to_s
43
+ end
44
+
45
+ redirect_back fallback_location: admin_system_waiting_room_path
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ class FreeVoteClaimController < BaseController
6
+ before_action :require_spree_current_user
7
+
8
+ def show
9
+ load_effective_config_onto_show
10
+
11
+ render_serialized_payload { serialize_resource(current_show) }
12
+ end
13
+
14
+ def create
15
+ result = SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
16
+ show: current_show,
17
+ user: spree_current_user,
18
+ tenant_id: MultiTenant.current_tenant_id,
19
+ votable_id: voting_session.id
20
+ ).call
21
+
22
+ if result.failure?
23
+ render_error_payload(result.error.to_s)
24
+ elsif result.value.nil?
25
+ head :no_content
26
+ else
27
+ render_serialized_payload { Spree::V2::Tenant::VotingCreditSerializer.new(result.value).serializable_hash }
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def resource_serializer
34
+ Spree::V2::Tenant::FreeVoteClaimSerializer
35
+ end
36
+
37
+ def load_effective_config_onto_show
38
+ config = voting_session.effective_voting_config(show_config: current_show.effective_voting_config)
39
+
40
+ current_show.claimed = !spree_current_user.free_vote_claimable?(show: current_show, votable_id: voting_session.id)
41
+ current_show.effective_free_vote_limit = config['free_vote_limit']
42
+ current_show.effective_free_vote_limit_type = config['free_vote_limit_type']
43
+ rescue ArgumentError
44
+ current_show.claimed = false
45
+ end
46
+
47
+ def voting_session
48
+ @voting_session ||= current_vendor.voting_sessions.find(params[:voting_session_id])
49
+ end
50
+
51
+ def current_show
52
+ @current_show ||= voting_session.show
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ class VotePackagesController < BaseController
6
+ def index
7
+ render_serialized_payload { serialize_collection(scope) }
8
+ end
9
+
10
+ private
11
+
12
+ def scope
13
+ current_vendor.vote_packages.where(status: :active, event_id: current_season.id)
14
+ end
15
+
16
+ def current_season
17
+ @current_season ||= current_vendor.shows
18
+ .where.not(parent_id: nil)
19
+ .find_by!(slug: params[:show_id])
20
+ end
21
+
22
+ def serialize_collection(collection)
23
+ collection_serializer.new(collection, include: resource_includes).serializable_hash
24
+ end
25
+
26
+ def collection_serializer
27
+ Spree::V2::Tenant::VotePackageSerializer
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -19,18 +19,47 @@ module SpreeCmCommissioner
19
19
  max_sessions - active_sessions
20
20
  end
21
21
 
22
- # This query required index. create them in Firebase beforehand.
23
- # Client side must create waiting_guests document with :queued_at & :allow_to_enter_room_at to null to allow fillter & order.
22
+ # This query requires an index; create it in Firebase beforehand.
23
+ # Client must create waiting_guests documents with :queued_at and :allow_to_enter_room_at set to nil to allow filter + order queries.
24
+ #
25
+ # Yesterday's guests are always older than today's, so fill from yesterday first, then use any
26
+ # leftover slots for today. This way no one queued before the midnight rollover gets skipped.
27
+ # e.g. 5 slots, 2 waiting in yesterday -> take both, then take 3 from today.
24
28
  def fetch_long_waiting_guests(available_slots)
25
- firestore.col('waiting_guests')
26
- .doc(current_date)
27
- .col('records')
29
+ previous_guests = eligible_guests_in(previous_records_path, available_slots)
30
+
31
+ # Pre-flip window: the lobby pointer still points at yesterday, so both paths resolve to the
32
+ # same partition — return now to avoid querying (and double-counting) it twice.
33
+ return previous_guests if records_path == previous_records_path
34
+
35
+ remaining_slots = available_slots - previous_guests.size
36
+ return previous_guests if remaining_slots <= 0
37
+
38
+ previous_guests + eligible_guests_in(records_path, remaining_slots)
39
+ end
40
+
41
+ def eligible_guests_in(records_path, limit)
42
+ firestore.col(records_path)
28
43
  .where('allow_to_enter_room_at', '==', nil)
29
44
  .order('queued_at')
30
- .limit(available_slots)
45
+ .limit(limit)
31
46
  .get.to_a
32
47
  end
33
48
 
49
+ # Published path is authoritative; fall back to the server's own date if not yet published.
50
+ def records_path
51
+ lobby_data&.dig(:waiting_guests_records_path).presence || default_records_path(current_date)
52
+ end
53
+
54
+ # Drain target is derived from the server date, never the (possibly stale) lobby pointer.
55
+ def previous_records_path
56
+ default_records_path(previous_date)
57
+ end
58
+
59
+ def default_records_path(date)
60
+ "waiting_guests/#{date}/records"
61
+ end
62
+
34
63
  # For alert waiting guests to enter room, we just update :allow_to_enter_room_at.
35
64
  # App will listen to firebase & start refresh session token to enter room.
36
65
  def calling_all(waiting_guests)
@@ -45,9 +74,22 @@ module SpreeCmCommissioner
45
74
  Time.zone.now.strftime('%Y-%m-%d')
46
75
  end
47
76
 
77
+ def previous_date
78
+ 1.day.ago.strftime('%Y-%m-%d')
79
+ end
80
+
48
81
  # When open app, app request to check whether room is full or not via Firebase instead of server to minimize server requests.
82
+ # merge: true so we preserve the published `waiting_guests_records_path` on the lobby doc.
49
83
  def mark_as(full:, available_slots:)
50
- firestore.col('waiting_rooms').doc('lobby').set({ full: full, available_slots: available_slots })
84
+ lobby_document.set({ full: full, available_slots: available_slots }, merge: true)
85
+ end
86
+
87
+ def lobby_data
88
+ @lobby_data ||= lobby_document.get.data
89
+ end
90
+
91
+ def lobby_document
92
+ @lobby_document ||= firestore.col('waiting_rooms').doc('lobby')
51
93
  end
52
94
 
53
95
  def fetch_max_sessions
@@ -0,0 +1,17 @@
1
+ # waiting_room_lobby_path_publisher:
2
+ # cron: "0 0 * * * Asia/Phnom_Penh" # Once per day at local midnight (date rollover)
3
+ # class: "SpreeCmCommissioner::WaitingRoom::PublishLobbyPathJob"
4
+ module SpreeCmCommissioner
5
+ module WaitingRoom
6
+ class PublishLobbyPathJob < ApplicationJob
7
+ queue_as :waiting_room
8
+
9
+ def perform
10
+ return if ENV['WAITING_ROOM_DISABLED'] == 'yes'
11
+
12
+ # call! so a publish failure raises (Sidekiq retries/alerts) instead of being silently swallowed.
13
+ SpreeCmCommissioner::WaitingRoom::PublishLobbyPath.call!
14
+ end
15
+ end
16
+ end
17
+ end
@@ -2,14 +2,17 @@ module SpreeCmCommissioner
2
2
  class Show < Spree::Taxon
3
3
  include SpreeCmCommissioner::StoreMetadata
4
4
 
5
- has_many :voting_credits, class_name: 'SpreeCmCommissioner::VotingCredit', as: :votable, dependent: :destroy
6
5
  belongs_to :show, class_name: 'SpreeCmCommissioner::Show', foreign_key: :parent_id, optional: true
6
+
7
+ has_many :voting_credits, class_name: 'SpreeCmCommissioner::VotingCredit', as: :votable, dependent: :destroy
7
8
  has_many :seasons, class_name: 'SpreeCmCommissioner::Show', foreign_key: :parent_id, inverse_of: :parent
8
9
  has_many :show_contestants, class_name: 'SpreeCmCommissioner::ShowContestant', inverse_of: :show, dependent: :destroy
9
10
  has_many :show_people_assignments, class_name: 'SpreeCmCommissioner::ShowPersonAssignment', dependent: :destroy
10
11
  has_many :show_people, through: :show_people_assignments, source: :show_person
11
12
  has_many :episodes, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
13
+ has_many :vote_packages, -> { where(product_type: :vote_package) }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
12
14
  has_many :voting_sessions, through: :episodes, class_name: 'SpreeCmCommissioner::VotingSession'
15
+
13
16
  has_one :current_episode, -> { current_or_next_upcoming }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
14
17
  has_one :current_voting_session, through: :current_episode
15
18
 
@@ -24,6 +27,8 @@ module SpreeCmCommissioner
24
27
  SHOW_TYPES = %w[show tournament boxing].freeze
25
28
 
26
29
  # Define standard voting configuration keys
30
+ attr_accessor :claimed, :effective_free_vote_limit, :effective_free_vote_limit_type
31
+
27
32
  store_accessor :voting_config,
28
33
  :free_vote_limit,
29
34
  :free_vote_limit_type,
@@ -64,12 +69,13 @@ module SpreeCmCommissioner
64
69
  "SpreeCmCommissioner::#{type}"
65
70
  end
66
71
 
67
- def free_vote_idempotency_key(user_id:, votable_id: nil)
68
- case free_vote_limit_type
72
+ def free_vote_idempotency_key(user_id:, votable_id: nil, limit_type: nil)
73
+ effective_limit_type = limit_type || free_vote_limit_type
74
+ case effective_limit_type
69
75
  when 'per_episode' then "free_claim::episode::#{votable_id}::user::#{user_id}"
70
76
  when 'per_voting_session' then "free_claim::voting_session::#{votable_id}::user::#{user_id}"
71
77
  when 'per_show' then "free_claim::show::#{id}::user::#{user_id}"
72
- else raise ArgumentError, "Invalid free_vote_limit_type: #{free_vote_limit_type}"
78
+ else raise ArgumentError, "Invalid free_vote_limit_type: #{effective_limit_type}"
73
79
  end
74
80
  end
75
81
 
@@ -196,6 +196,18 @@ module SpreeCmCommissioner
196
196
  notification
197
197
  end
198
198
 
199
+ def free_vote_claimable?(show:, votable_id: nil)
200
+ voting_session = votable_id.present? ? SpreeCmCommissioner::VotingSession.find_by(id: votable_id, show: show) : nil
201
+ effective_config = voting_session&.effective_voting_config || show.effective_voting_config
202
+ limit_type = effective_config['free_vote_limit_type']
203
+
204
+ return false unless limit_type
205
+
206
+ resolved_votable_id = limit_type == 'per_episode' ? voting_session&.episode_id : votable_id
207
+ idempotency_key = show.free_vote_idempotency_key(user_id: id, votable_id: resolved_votable_id, limit_type: limit_type)
208
+ !voting_credit_transactions.exists?(idempotency_key: idempotency_key)
209
+ end
210
+
199
211
  def push_notificable?
200
212
  return false if device_tokens_count.blank?
201
213
 
@@ -118,6 +118,7 @@ module SpreeCmCommissioner
118
118
  }, class_name: 'Spree::Taxonomy', dependent: :destroy
119
119
 
120
120
  base.has_many :merchandise_products, -> { where(product_type: :ecommerce, event_id: nil) }, class_name: 'Spree::Product'
121
+ base.has_many :vote_packages, -> { where(product_type: :vote_package) }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :vendor_id
121
122
 
122
123
  # Create maintaining task to purge vendor related caches
123
124
  base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
@@ -103,12 +103,12 @@ module SpreeCmCommissioner
103
103
  end
104
104
  end
105
105
 
106
- def effective_voting_config
106
+ def effective_voting_config(show_config: nil)
107
107
  return {} unless show
108
108
 
109
- config = parse_json_config(voting_config)
110
- show_config = show.effective_voting_config
111
- config.reverse_merge(show_config || {})
109
+ show_config ||= show.effective_voting_config
110
+ session_config = parse_json_config(voting_config).compact
111
+ show_config.merge(session_config)
112
112
  end
113
113
 
114
114
  def effective_fraud_config
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ module V2
3
+ module Tenant
4
+ class FreeVoteClaimSerializer < BaseSerializer
5
+ attributes :claimed, :effective_free_vote_limit, :effective_free_vote_limit_type
6
+ end
7
+ end
8
+ end
9
+ end
@@ -2,7 +2,10 @@ module Spree
2
2
  module V2
3
3
  module Tenant
4
4
  class VotePackageSerializer < BaseSerializer
5
- attributes :name, :price, :compare_at_price, :available_on, :discontinue_on, :status, :vote_credits
5
+ attributes :name, :price, :compare_at_price, :currency, :display_price,
6
+ :available_on, :discontinue_on, :status, :vote_credits
7
+
8
+ has_many :variants, serializer: Spree::V2::Tenant::VariantSerializer
6
9
  end
7
10
  end
8
11
  end
@@ -1,82 +1,57 @@
1
- # Mobile can check that where ClaimFreeVotes Screen should Appear based on the show's voting_config.
2
- # free_vote_limit and voting_config.free_vote_limit_type, then call this service to claim free votes.
3
- #
4
- # Grants free votes to a user based on the show's free_vote_limit_type:
5
- # - per_show : once per show (no extra params needed)
6
- # - per_episode : once per episode (pass votable_type: 'SpreeCmCommissioner::ShowEpisode', votable_id: episode.id)
7
- # - per_voting_session: once per voting session (pass votable_type: 'SpreeCmCommissioner::VotingSession', votable_id: voting_session.id)
1
+ # Grants free votes based on the effective config (voting session overrides show):
2
+ # - per_show : once per show (votable_id optional)
3
+ # - per_episode : once per episode (votable_id: voting_session.id)
4
+ # - per_voting_session: once per voting session (votable_id: voting_session.id)
8
5
  #
6
+ # votable_id is always a VotingSession ID. The episode is derived from it when needed.
7
+ # The credit wallet target is determined by the show's voting_credit_scope.
9
8
  # Idempotent: a unique idempotency_key on the transaction prevents double-claiming.
10
- #
11
- # Usage:
12
- # Show with per_show limit:
13
- # SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
14
- # show: show,
15
- # user: user,
16
- # tenant_id: 1
17
- # ).call
18
- #
19
- # Show with per_episode limit:
20
- # SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
21
- # show: show,
22
- # user: user,
23
- # tenant_id: 1,
24
- # votable_type: 'SpreeCmCommissioner::ShowEpisode',
25
- # votable_id: episode.id
26
- # ).call
27
- #
28
- # Show with per_voting_session limit:
29
- # SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
30
- # show: show,
31
- # user: user,
32
- # tenant_id: 1,
33
- # votable_type: 'SpreeCmCommissioner::VotingSession',
34
- # votable_id: voting_session.id
35
- # ).call
36
9
  module SpreeCmCommissioner
37
10
  module VotingCredits
38
11
  class ClaimFreeVotes
39
12
  prepend ::Spree::ServiceModule::Base
40
13
 
41
- attr_reader :show, :user, :tenant_id, :votable_type, :votable_id
14
+ attr_reader :show, :user, :tenant_id, :votable_id
42
15
 
43
- def initialize(show:, user:, tenant_id:, votable_type: nil, votable_id: nil)
16
+ def initialize(show:, user:, tenant_id:, votable_id: nil)
44
17
  @show = show
45
18
  @user = user
46
19
  @tenant_id = tenant_id
47
- @votable_type = votable_type
48
20
  @votable_id = votable_id
49
21
  end
50
22
 
51
- # Returns the VotingCredit after granting free votes,
52
- # or returns msg if free votes cannot be granted (e.g. limit reached).
53
- def call # rubocop:disable Metrics/AbcSize
54
- return success(nil) unless show.free_vote_limit_type
23
+ def call
24
+ return success(nil) unless effective_limit_type
55
25
 
56
- idempotency_key = show.free_vote_idempotency_key(user_id: user.id, votable_id: votable_id)
26
+ return failure(nil, 'votable_id is required for per_episode/per_voting_session') if requires_votable_id? && votable_id.blank?
57
27
 
58
- # idempotency_key is uniquely indexed faster than scoping through user.voting_transactions association.
59
- if SpreeCmCommissioner::VotingCreditTransaction.exists?(idempotency_key: idempotency_key)
60
- return failure(nil, 'Free votes have already been claimed')
61
- end
28
+ key_votable_id = effective_limit_type == 'per_episode' ? resolved_voting_session.episode_id : votable_id
29
+ idempotency_key = show.free_vote_idempotency_key(user_id: user.id, votable_id: key_votable_id, limit_type: effective_limit_type)
62
30
 
63
- amount = show.free_vote_limit.to_i
31
+ return failure(nil, 'Free votes have already been claimed') if already_claimed?(idempotency_key)
32
+
33
+ amount = effective_limit.to_i
64
34
 
65
35
  return failure(nil, "Free votes are not available for #{show.voting_credit_scope}") if amount <= 0
66
36
 
67
- case show.free_vote_limit_type
68
- when 'per_episode', 'per_voting_session'
69
- if votable_type.blank? || votable_id.blank?
70
- return failure(nil, 'votable_type and votable_id are required for per_episode/per_voting_session')
71
- end
72
- end
37
+ credit = grant_votes(amount: amount, idempotency_key: idempotency_key)
73
38
 
39
+ success(credit)
40
+ rescue ActiveRecord::RecordNotUnique
41
+ failure(nil, 'Free votes have already been claimed')
42
+ rescue StandardError => e
43
+ failure(nil, e.message)
44
+ end
45
+
46
+ private
47
+
48
+ def grant_votes(amount:, idempotency_key:)
74
49
  credit = nil
75
50
 
76
51
  ActiveRecord::Base.transaction do
77
52
  credit = user.voting_credits.active.find_or_create_by!(
78
53
  tenant_id: tenant_id,
79
- votable: votable
54
+ votable: credit_votable
80
55
  ) { |c| c.category = :promo }
81
56
 
82
57
  credit.with_lock do
@@ -90,30 +65,52 @@ module SpreeCmCommissioner
90
65
  amount: amount,
91
66
  originator: show,
92
67
  free_claim_votable_id: votable_id,
68
+ idempotency_key: idempotency_key,
93
69
  user_total_amount: credit.available_votes,
94
70
  memo: "Free claim: #{amount} votes | credit_scope: #{show.voting_credit_scope} | key: #{idempotency_key}"
95
71
  )
96
72
  end
97
73
 
98
- success(credit)
99
- rescue ActiveRecord::RecordNotUnique
100
- # Two concurrent requests can both pass the existence check before either commits.
101
- # The one that loses the unique-index race lands here — treat it as already claimed.
102
- failure(nil, 'Free votes have already been claimed')
103
- rescue StandardError => e
104
- failure(nil, e.message)
74
+ credit
105
75
  end
106
76
 
107
- # Always find votable by the show's credit_votable_type, to ensure consistency with where the credits are allocated.
108
- # Resolves through the show association to prevent claiming against unrelated votable_ids.
109
- def votable
77
+ def credit_votable
110
78
  case show.credit_votable_type
111
- when 'SpreeCmCommissioner::Show' then show
112
- when 'SpreeCmCommissioner::ShowEpisode' then show.episodes.find(votable_id)
113
- when 'SpreeCmCommissioner::VotingSession' then show.voting_sessions.find(votable_id)
79
+ when 'SpreeCmCommissioner::Show' then show
80
+ when 'SpreeCmCommissioner::ShowEpisode' then SpreeCmCommissioner::ShowEpisode.find(resolved_voting_session.episode_id)
81
+ when 'SpreeCmCommissioner::VotingSession' then resolved_voting_session
114
82
  else raise ActiveRecord::RecordNotFound, "Unknown credit_votable_type: #{show.credit_votable_type}"
115
83
  end
116
84
  end
85
+
86
+ def resolved_voting_session
87
+ @resolved_voting_session ||= SpreeCmCommissioner::VotingSession.find_by!(id: votable_id, show: show)
88
+ end
89
+
90
+ def effective_voting_config
91
+ @effective_voting_config ||=
92
+ if votable_id.present?
93
+ resolved_voting_session.effective_voting_config(show_config: show.effective_voting_config)
94
+ else
95
+ show.effective_voting_config
96
+ end
97
+ end
98
+
99
+ def effective_limit_type
100
+ effective_voting_config['free_vote_limit_type']
101
+ end
102
+
103
+ def effective_limit
104
+ effective_voting_config['free_vote_limit']
105
+ end
106
+
107
+ def requires_votable_id?
108
+ effective_limit_type.in?(%w[per_episode per_voting_session])
109
+ end
110
+
111
+ def already_claimed?(idempotency_key)
112
+ SpreeCmCommissioner::VotingCreditTransaction.exists?(idempotency_key: idempotency_key)
113
+ end
117
114
  end
118
115
  end
119
116
  end
@@ -0,0 +1,48 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ module SpreeCmCommissioner
4
+ module WaitingRoom
5
+ # Publishes the server-owned waiting-guests records path to the lobby document, so mobile
6
+ # and the waiting-room caller share one source of truth for the date partition instead of
7
+ # each building it from their own (possibly skewed) clock/timezone.
8
+ #
9
+ # CRON note: ecause we use Time.zone.now to determine the path, it's important that the CRON job runs at the same
10
+ # timezone as well — otherwise the cron could fire before/after Time.zone.now rolls to the new date.
11
+ # Example: cron: "0 0 * * * Asia/Phnom_Penh"
12
+ #
13
+ # Caching note: No caching needed since the service is expected to be called once per day
14
+ # so a single daily merge write is negligible.
15
+ class PublishLobbyPath
16
+ prepend ::Spree::ServiceModule::Base
17
+ extend SpreeCmCommissioner::ServiceModuleThrowable
18
+
19
+ def call
20
+ # merge: true so we never clobber the lobby's `full` / `available_slots` fields.
21
+ lobby_document.set({ waiting_guests_records_path: records_path }, merge: true)
22
+
23
+ success(records_path: records_path)
24
+ rescue StandardError => e
25
+ failure(nil, e.message)
26
+ end
27
+
28
+ private
29
+
30
+ # Server date is authoritative.
31
+ def records_path
32
+ @records_path ||= "waiting_guests/#{Time.zone.now.strftime('%Y-%m-%d')}/records"
33
+ end
34
+
35
+ def lobby_document
36
+ @lobby_document ||= firestore.col('waiting_rooms').doc('lobby')
37
+ end
38
+
39
+ def firestore
40
+ @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account)
41
+ end
42
+
43
+ def service_account
44
+ @service_account ||= Rails.application.credentials.cloud_firestore_service_account
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ module SpreeCmCommissioner
4
+ # Reads the lobby document (waiting_rooms/lobby) — the read-side counterpart to
5
+ # WaitingRoom::PublishLobbyPath. Kept separate from WaitingRoomSystemMetadataFetcher,
6
+ # which owns the unrelated metadata/system document.
7
+ class WaitingRoomLobbyMetadataFetcher
8
+ attr_reader :document_data
9
+
10
+ def initialize(firestore: nil)
11
+ @firestore = firestore if firestore.present?
12
+ end
13
+
14
+ def load_document_data
15
+ @document_data = document.get.data || {}
16
+ end
17
+
18
+ # firebase metadata
19
+
20
+ # server-owned date partition — see WaitingRoom::PublishLobbyPath.
21
+ def waiting_guests_records_path
22
+ document_data[:waiting_guests_records_path]
23
+ end
24
+
25
+ # written by WaitingGuestsCaller#mark_as.
26
+ def full
27
+ document_data[:full]
28
+ end
29
+
30
+ # written by WaitingGuestsCaller#mark_as.
31
+ def available_slots
32
+ document_data[:available_slots]
33
+ end
34
+
35
+ def document
36
+ @document ||= firestore.col('waiting_rooms').doc('lobby')
37
+ end
38
+
39
+ def firestore
40
+ @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account)
41
+ end
42
+
43
+ def service_account
44
+ @service_account ||= Rails.application.credentials.cloud_firestore_service_account
45
+ end
46
+ end
47
+ end
@@ -4,6 +4,7 @@
4
4
  <%= button_link_to Spree.t(:force_pull), force_pull_admin_system_waiting_room_path, method: :post, class: "btn btn-outline-primary" %>
5
5
  <% end %>
6
6
 
7
+ <h6 class="mb-2 text-muted">System</h6>
7
8
  <div class="bg-white border rounded table-responsive">
8
9
  <table class="table" data-hook>
9
10
  <thead class="text-muted">
@@ -71,3 +72,55 @@
71
72
  </tbody>
72
73
  </table>
73
74
  </div>
75
+
76
+ <h6 class="mt-4 mb-2 text-muted">Lobby</h6>
77
+ <div class="bg-white border rounded table-responsive">
78
+ <table class="table" data-hook>
79
+ <thead class="text-muted">
80
+ <tr data-hook="admin_system_lobby_headers">
81
+ <th><%= Spree.t(:field_name) %></th>
82
+ <th><%= Spree.t(:current_value) %></th>
83
+ <th></th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ <% lobby_items = [
88
+ {
89
+ field_name: :waiting_guests_records_path,
90
+ value: @lobby_fetcher.waiting_guests_records_path.presence || "Not published yet",
91
+ reset_path: publish_lobby_path_admin_system_waiting_room_path
92
+ },
93
+ {
94
+ field_name: :full,
95
+ value: @lobby_fetcher.full ? "Yes" : "No",
96
+ },
97
+ {
98
+ field_name: :available_slots,
99
+ value: @lobby_fetcher.available_slots,
100
+ },
101
+ ] %>
102
+
103
+ <% lobby_items.each do |item| %>
104
+ <tr data-hook="admin_system_lobby_rows">
105
+ <td>
106
+ <%= item[:field_name].to_s.humanize %>
107
+ <span class="badge text-lowercase"><%= item[:field_name] %></span>
108
+ </td>
109
+ <td>
110
+ <%= item[:value] %>
111
+ </td>
112
+ <td>
113
+ <% if item[:reset_path].present? %>
114
+ <%= link_to_with_icon('arrow-counterclockwise.svg', "Publish path now", item[:reset_path],
115
+ method: :post,
116
+ remote: false,
117
+ class: 'icon_link btn btn-sm outline text-dark m-0 p-0',
118
+ data: { confirm: "Publish today's records path to the lobby now?" }, no_text: true
119
+ ) %>
120
+ <% end %>
121
+ </td>
122
+ </tr>
123
+ <% end %>
124
+ </tbody>
125
+ </table>
126
+ </div>
data/config/routes.rb CHANGED
@@ -14,6 +14,7 @@ Spree::Core::Engine.add_routes do
14
14
  resource :waiting_room, controller: :waiting_room, only: [:show] do
15
15
  collection do
16
16
  post :force_pull
17
+ post :publish_lobby_path
17
18
  post :modify_multiplier
18
19
  post :modify_max_thread_count
19
20
  end
@@ -604,10 +605,11 @@ Spree::Core::Engine.add_routes do
604
605
  end
605
606
  resources :contestants, controller: :show_contestants, only: %i[show]
606
607
  resources :elimination_sessions, controller: :show_elimination_sessions, only: %i[index]
607
- resources :free_vote_claims, only: :create
608
+ resources :vote_packages, only: :index
608
609
  end
609
610
 
610
611
  resources :voting_sessions, only: [] do
612
+ resource :free_vote_claim, controller: :free_vote_claim, only: %i[show create]
611
613
  resources :voting_contestants, only: :index
612
614
  resources :voting_credits, only: :index
613
615
  resources :votes, only: %i[create index]
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.8.3.pre.pre12'.freeze
2
+ VERSION = '2.8.3.pre.pre14'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.3.pre.pre12
4
+ version: 2.8.3.pre.pre14
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-17 00:00:00.000000000 Z
11
+ date: 2026-06-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1052,7 +1052,7 @@ files:
1052
1052
  - app/controllers/spree/api/v2/tenant/customer_notifications_controller.rb
1053
1053
  - app/controllers/spree/api/v2/tenant/dynamic_field_options_controller.rb
1054
1054
  - app/controllers/spree/api/v2/tenant/episodes_controller.rb
1055
- - app/controllers/spree/api/v2/tenant/free_vote_claims_controller.rb
1055
+ - app/controllers/spree/api/v2/tenant/free_vote_claim_controller.rb
1056
1056
  - app/controllers/spree/api/v2/tenant/guests_controller.rb
1057
1057
  - app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb
1058
1058
  - app/controllers/spree/api/v2/tenant/id_cards_controller.rb
@@ -1091,6 +1091,7 @@ files:
1091
1091
  - app/controllers/spree/api/v2/tenant/user_device_tokens_controller.rb
1092
1092
  - app/controllers/spree/api/v2/tenant/user_registration_with_pin_codes_controller.rb
1093
1093
  - app/controllers/spree/api/v2/tenant/vendors_controller.rb
1094
+ - app/controllers/spree/api/v2/tenant/vote_packages_controller.rb
1094
1095
  - app/controllers/spree/api/v2/tenant/votes_controller.rb
1095
1096
  - app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb
1096
1097
  - app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb
@@ -1416,6 +1417,7 @@ files:
1416
1417
  - app/jobs/spree_cm_commissioner/voting_credit_allocation_job.rb
1417
1418
  - app/jobs/spree_cm_commissioner/voting_credit_de_allocation_job.rb
1418
1419
  - app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb
1420
+ - app/jobs/spree_cm_commissioner/waiting_room/publish_lobby_path_job.rb
1419
1421
  - app/jobs/spree_cm_commissioner/waiting_room_latest_system_metadata_puller_job.rb
1420
1422
  - app/jobs/spree_cm_commissioner/waiting_room_session_firebase_logger_job.rb
1421
1423
  - app/jobs/spree_cm_commissioner/webhook_subscriber_orders_sender_job.rb
@@ -1987,6 +1989,7 @@ files:
1987
1989
  - app/serializers/spree/v2/tenant/digital_link_serializer.rb
1988
1990
  - app/serializers/spree/v2/tenant/dynamic_field_option_serializer.rb
1989
1991
  - app/serializers/spree/v2/tenant/dynamic_field_serializer.rb
1992
+ - app/serializers/spree/v2/tenant/free_vote_claim_serializer.rb
1990
1993
  - app/serializers/spree/v2/tenant/guest_card_class_serializer.rb
1991
1994
  - app/serializers/spree/v2/tenant/guest_dynamic_field_serializer.rb
1992
1995
  - app/serializers/spree/v2/tenant/guest_serializer.rb
@@ -2341,6 +2344,8 @@ files:
2341
2344
  - app/services/spree_cm_commissioner/voting_leaderboards/calculate_score.rb
2342
2345
  - app/services/spree_cm_commissioner/voting_leaderboards/combined_result.rb
2343
2346
  - app/services/spree_cm_commissioner/voting_sessions/finalize.rb
2347
+ - app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb
2348
+ - app/services/spree_cm_commissioner/waiting_room_lobby_metadata_fetcher.rb
2344
2349
  - app/services/spree_cm_commissioner/waiting_room_system_metadata_fetcher.rb
2345
2350
  - app/services/spree_cm_commissioner/waiting_room_system_metadata_setter.rb
2346
2351
  - app/services/spree_cm_commissioner/webhooks/subscribers/handle_request_decorator.rb
@@ -1,37 +0,0 @@
1
- module Spree
2
- module Api
3
- module V2
4
- module Tenant
5
- class FreeVoteClaimsController < BaseController
6
- before_action :require_spree_current_user
7
-
8
- def create
9
- show = current_vendor.shows.find(params[:show_id])
10
-
11
- result = SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
12
- show: show,
13
- user: spree_current_user,
14
- tenant_id: MultiTenant.current_tenant_id,
15
- votable_type: params[:votable_type],
16
- votable_id: params[:votable_id]
17
- ).call
18
-
19
- if result.failure?
20
- render_error_payload(result.error.to_s)
21
- elsif result.value.nil?
22
- head :no_content
23
- else
24
- render_serialized_payload { serialize_resource(result.value) }
25
- end
26
- end
27
-
28
- private
29
-
30
- def resource_serializer
31
- Spree::V2::Tenant::VotingCreditSerializer
32
- end
33
- end
34
- end
35
- end
36
- end
37
- end