spree_cm_commissioner 2.8.2.pre.pre.2 → 2.8.2.pre.pre.3

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: 902f245f53de7f894cef3c6187916474fc53222d0c60fd2713c131c5712816a7
4
- data.tar.gz: c9c02081b81a4f5a88d639c149c59360daf931348d09e0bffcf2006dd10374c9
3
+ metadata.gz: d81921f1e2c71c3740ce4b4d0c933c5ecc1c1c1e008eacf01636e95c2d0078e3
4
+ data.tar.gz: 4b02d2667e7d5a441e37315ecb0c91de142b529a595ea14a350863e75b22bd0b
5
5
  SHA512:
6
- metadata.gz: 745c769444dcf93d625e5cb5a3fa21979c79fd15fc38fe580cd35e537849308d6fd4d63cc8da3b65808b099eaa7fffbade7c5f65cfbcef6122feeeb99a34b93a
7
- data.tar.gz: 29a3e784ab688a3f1348b57fc5c7df59cc659345e1d851f04be81ee1a662786466db70f25aec24d4712a422e4c1c4cbdac1078c1f0990fb8804a9f2f134b2f49
6
+ metadata.gz: 480b051b7f31a87059d9c6f396e6a6e0c2c48b0a38432c7c93d027cbf038263a500945afdb2c8d9c6dbbed92a2cc857a03eaff25bdb57f31b6e7f429dce6556f
7
+ data.tar.gz: bc516685858c51491db8801c3004f2f8c095c1b2727958877e911312dcb7039feccc8c318c9d88ff8b1c56f88c933478bd05a1664d480dd4a2e0c5dca0a64190
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.8.2.pre.pre.2)
37
+ spree_cm_commissioner (2.8.2.pre.pre.3)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -9,7 +9,10 @@ module Spree
9
9
  def collection
10
10
  @collection ||= @voting_session
11
11
  .voting_contestants
12
- .includes(show_contestant: :show_contestant_images)
12
+ .includes(:voting_session, :show_contestant,
13
+ :show_contestant_images, :show_contestant_videos,
14
+ :advanced_to, :advanced_from
15
+ )
13
16
  .order(:id)
14
17
  end
15
18
 
@@ -75,7 +75,8 @@ module SpreeCmCommissioner
75
75
  :max_votes_per_minute_per_user,
76
76
  :max_votes_per_minute_per_ip,
77
77
  :max_accounts_per_device,
78
- :block_vpn
78
+ :block_vpn,
79
+ :max_votes_per_contestant_per_user
79
80
 
80
81
  def show?
81
82
  depth == 1
@@ -11,7 +11,8 @@ module SpreeCmCommissioner
11
11
  ip_cluster: 2,
12
12
  anomaly: 3,
13
13
  new_account: 4,
14
- manual_flag: 5
14
+ manual_flag: 5,
15
+ vote_cap: 6
15
16
  }
16
17
  enum severity: { low: 0, medium: 1, high: 2, critical: 3 }
17
18
  enum action_taken: { allowed: 0, blocked: 1, flagged: 2, invalidated: 3 }
@@ -42,5 +42,13 @@ module SpreeCmCommissioner
42
42
  def special_rule_type
43
43
  (special_rules || {})['type']
44
44
  end
45
+
46
+ def advanced_from_name
47
+ advanced_from&.name
48
+ end
49
+
50
+ def advanced_to_name
51
+ advanced_to&.name
52
+ end
45
53
  end
46
54
  end
@@ -1,4 +1,5 @@
1
1
  module SpreeCmCommissioner
2
+ # rubocop:disable Metrics/ClassLength
2
3
  class VotingSession < SpreeCmCommissioner::Base
3
4
  include SpreeCmCommissioner::StoreMetadata
4
5
 
@@ -6,6 +7,8 @@ module SpreeCmCommissioner
6
7
  belongs_to :show, class_name: 'SpreeCmCommissioner::Show', optional: false
7
8
  belongs_to :episode, class_name: 'Spree::Product', optional: false
8
9
 
10
+ has_one :season, through: :episode, class_name: 'SpreeCmCommissioner::Show', source: :season
11
+
9
12
  has_many :voting_credits, class_name: 'SpreeCmCommissioner::VotingCredit', as: :votable, dependent: :destroy
10
13
  has_many :voting_contestants, class_name: 'SpreeCmCommissioner::VotingContestant', dependent: :destroy
11
14
  has_many :eliminated_voting_contestants, lambda {
@@ -20,6 +23,7 @@ module SpreeCmCommissioner
20
23
 
21
24
  enum status: { disabled: 0, enabled: 1, closed: 2, finalized: 3 }
22
25
  enum scoring_model: { manual: 0, geometric: 1, harmonic: 2, weighted_sum: 3, arithmetic: 4 }
26
+ enum session_type: { vote: 0, manual_advance: 1, save_vote: 2, pre_vote: 3 }
23
27
 
24
28
  self.whitelisted_ransackable_attributes = %w[name status]
25
29
 
@@ -67,7 +71,8 @@ module SpreeCmCommissioner
67
71
  :max_votes_per_minute_per_user,
68
72
  :max_votes_per_minute_per_ip,
69
73
  :max_accounts_per_device,
70
- :block_vpn
74
+ :block_vpn,
75
+ :max_votes_per_contestant_per_user
71
76
 
72
77
  # Define standard voting configuration keys
73
78
  store_accessor :voting_config,
@@ -138,9 +143,9 @@ module SpreeCmCommissioner
138
143
  # Show contestants expected in this session but not yet assigned.
139
144
  # episode.season.show_contestants → all contestants on the season this episode belongs to.
140
145
  # show_contestants → already assigned to this voting session.
141
- # e.g. season has [Alice, Bob, Carol]; session only has [Alice] → returns [Bob, Carol]
146
+ # e.g. season has [Alice(active), Bob(active), Carol(eliminated)]; session only has [Alice] → returns [Bob]
142
147
  def missing_show_contestants
143
- episode.season.show_contestants.where.not(id: show_contestants.select(:id)).ordered
148
+ episode.season.show_contestants.active.where.not(id: show_contestants.select(:id)).ordered
144
149
  end
145
150
 
146
151
  def max_votes_per_user
@@ -220,4 +225,5 @@ module SpreeCmCommissioner
220
225
  {}
221
226
  end
222
227
  end
228
+ # rubocop:enable Metrics/ClassLength
223
229
  end
@@ -17,6 +17,9 @@ module Spree
17
17
  end
18
18
  end
19
19
 
20
+ has_many :episodes, serializer: Spree::V2::Tenant::ShowEpisodeSerializer
21
+ has_many :voting_sessions, serializer: Spree::V2::Tenant::VotingSessionSerializer
22
+
20
23
  has_one :current_episode, serializer: Spree::V2::Tenant::ShowEpisodeSerializer
21
24
  has_one :current_voting_session, serializer: Spree::V2::Tenant::VotingSessionSerializer
22
25
  has_one :app_banner, serializer: Spree::V2::Tenant::AssetSerializer
@@ -4,9 +4,9 @@ module Spree
4
4
  class VotingContestantSerializer < BaseSerializer
5
5
  set_type :voting_contestant
6
6
 
7
- attributes :show_id, :show_contestant_id, :name, :contestant_number, :vote_number, :category, :gender,
8
- :vote_count, :eliminated, :eliminated_via, :eliminated_at, :advanced_to_type, :advanced_to_id, :special_rules,
9
- :created_at, :updated_at, :advanced_from_type, :advanced_from_id, :unique_voter_count, :preferences
7
+ attributes :show_contestant_id, :name, :contestant_number, :vote_number,
8
+ :eliminated, :eliminated_via,
9
+ :advanced_from_name, :advanced_to_name, :confirmed_rank
10
10
 
11
11
  attribute :voting_session_id, &:voting_session_id
12
12
 
@@ -16,6 +16,7 @@ module Spree
16
16
 
17
17
  has_many :show_contestant_images, serializer: ::Spree::V2::Tenant::AssetSerializer
18
18
  has_many :show_contestant_videos, serializer: ::Spree::V2::Tenant::VideoSerializer
19
+ belongs_to :voting_session, serializer: ::Spree::V2::Tenant::VotingSessionSerializer
19
20
  end
20
21
  end
21
22
  end
@@ -2,8 +2,8 @@ module Spree
2
2
  module V2
3
3
  module Tenant
4
4
  class VotingSessionSerializer < BaseSerializer
5
- attributes :name, :opens_at, :closes_at, :status, :live_stream_enabled, :live_stream_thumbnail, :live_stream_title, :live_stream_description,
6
- :created_at
5
+ attributes :opens_at, :closes_at, :status, :session_type, :name, :public_metadata,
6
+ :live_stream_enabled, :live_stream_thumbnail, :live_stream_title, :live_stream_description
7
7
 
8
8
  attribute :live_stream_url, &:embedded_live_stream_url
9
9
  attribute :can_vote, &:can_vote?
@@ -1,10 +1,11 @@
1
1
  # Real-time fraud detection for vote submissions.
2
2
  #
3
- # Runs four checks in order:
4
- # 1. User rate limit – max votes per minute per user
5
- # 2. IP rate limit – max votes per minute per IP
6
- # 3. Device account limit – max distinct accounts per device fingerprint
7
- # 4. VPN / proxy block – reject known VPN IPs (when enabled)
3
+ # Runs five checks in order:
4
+ # 1. User rate limit – max votes per minute per user
5
+ # 2. IP rate limit – max votes per minute per IP
6
+ # 3. Device account limit – max distinct accounts per device fingerprint
7
+ # 4. VPN / proxy block – reject known VPN IPs (when enabled)
8
+ # 5. Contestant vote cap – max total votes per user per contestant for the session
8
9
 
9
10
  # On any violation, raises RuntimeError with an I18n user-facing message
10
11
  # and enqueues a VoteFraudEventJob asynchronously.
@@ -18,7 +19,8 @@ module SpreeCmCommissioner
18
19
  'max_votes_per_minute_per_user' => 5,
19
20
  'max_votes_per_minute_per_ip' => 10,
20
21
  'max_accounts_per_device' => 3,
21
- 'block_vpn' => false
22
+ 'block_vpn' => false,
23
+ 'max_votes_per_contestant_per_user' => 0 # 0 = disabled (opt-in)
22
24
  }.freeze
23
25
 
24
26
  RATE_WINDOW = 60 # seconds
@@ -39,6 +41,7 @@ module SpreeCmCommissioner
39
41
  check_ip_rate_limit!
40
42
  check_device_account_limit!
41
43
  check_vpn_block!
44
+ check_contestant_vote_cap!
42
45
 
43
46
  success(nil)
44
47
  rescue Redis::BaseError, ConnectionPool::TimeoutError => e
@@ -158,6 +161,23 @@ module SpreeCmCommissioner
158
161
  raise I18n.t('voting.errors.vpn_blocked')
159
162
  end
160
163
 
164
+ # --- Check 5: Contestant vote cap ---
165
+
166
+ def check_contestant_vote_cap!
167
+ return if contestant.nil?
168
+
169
+ threshold = config_value('max_votes_per_contestant_per_user')
170
+ return if threshold.nil? || threshold <= 0
171
+
172
+ key = "fraud:cap:contestant:#{session_id}:#{contestant.id}:#{user_identifier}"
173
+ count = increment_with_expiry(key, session_ttl, by: quantity)
174
+
175
+ return unless count > threshold
176
+
177
+ log_fraud_event(:vote_cap, :medium)
178
+ raise I18n.t('voting.errors.contestant_vote_cap_exceeded')
179
+ end
180
+
161
181
  # --- Redis helpers ---
162
182
 
163
183
  # Atomically increments a counter key and sets its TTL on the first write.
@@ -3,22 +3,28 @@ module SpreeCmCommissioner
3
3
  class CleanupExpired
4
4
  prepend ::Spree::ServiceModule::Base
5
5
 
6
- BATCH_SIZE = 1000
6
+ BATCH_SIZE = 500
7
+
8
+ # The access tokens set by doorkeeper to expire in 1 day (cm-market-server/config/initializers/doorkeeper.rb),
9
+ # so we set the threshold to 90 days to make sure all expired tokens are cleaned up.
7
10
  EXPIRATION_THRESHOLD_DAYS = 90
8
11
 
9
12
  def call
10
- cutoff_date = EXPIRATION_THRESHOLD_DAYS.days.ago
13
+ cutoff_time = EXPIRATION_THRESHOLD_DAYS.days.ago
11
14
  total_deleted = 0
12
15
 
16
+ # oauth_access_tokens is a standalone table with no foreign keys or associations
17
+ # pointing to it (checked cm-market-server/db/schema.rb), so it is safe to use
18
+ # delete_all here for faster bulk cleanup.
13
19
  Spree::OauthAccessToken
14
- .where('revoked_at IS NOT NULL OR (expires_in IS NOT NULL AND created_at + make_interval(secs => expires_in) < ?)', cutoff_date)
20
+ .where('created_at < ?', cutoff_time)
15
21
  .in_batches(of: BATCH_SIZE) do |relation|
16
22
  deleted_count = relation.delete_all
17
23
  total_deleted += deleted_count
18
24
  end
19
25
 
20
- log_cleanup_result(total_deleted, cutoff_date)
21
- success(total_deleted: total_deleted, cutoff_date: cutoff_date, batch_size: BATCH_SIZE, expiration_threshold_days: EXPIRATION_THRESHOLD_DAYS)
26
+ log_cleanup_result(total_deleted, cutoff_time)
27
+ success(total_deleted: total_deleted, cutoff_time: cutoff_time, batch_size: BATCH_SIZE, expiration_threshold_days: EXPIRATION_THRESHOLD_DAYS)
22
28
  rescue StandardError => e
23
29
  log_error(e)
24
30
  failure(nil, e.message)
@@ -26,12 +32,12 @@ module SpreeCmCommissioner
26
32
 
27
33
  private
28
34
 
29
- def log_cleanup_result(total_deleted, cutoff_date)
35
+ def log_cleanup_result(total_deleted, cutoff_time)
30
36
  CmAppLogger.log(
31
37
  label: 'SpreeCmCommissioner::OauthAccessTokens::CleanupExpired completed',
32
38
  data: {
33
39
  total_deleted: total_deleted,
34
- cutoff_date: cutoff_date,
40
+ cutoff_time: cutoff_time,
35
41
  batch_size: BATCH_SIZE,
36
42
  expiration_threshold_days: EXPIRATION_THRESHOLD_DAYS
37
43
  }
@@ -22,6 +22,11 @@ module SpreeCmCommissioner
22
22
  return if target_ids.empty?
23
23
 
24
24
  @episode.season.show_contestants.where(id: target_ids).find_each do |show_contestant|
25
+ if @voting_session.save_vote? && !show_contestant.eliminated?
26
+ raise 'Only eliminated contestants can be added to a Save Contestants session. ' \
27
+ "#{show_contestant.name} is #{show_contestant.status}."
28
+ end
29
+
25
30
  @voting_session.voting_contestants.find_or_create_by!(show_contestant: show_contestant) do |voting_contestant|
26
31
  voting_contestant.show_id = @voting_session.show_id
27
32
  end
@@ -3,10 +3,18 @@ module SpreeCmCommissioner
3
3
  class Finalize
4
4
  prepend ::Spree::ServiceModule::Base
5
5
 
6
- def call(voting_session:)
6
+ def call(voting_session:, save_advance_to_session_id: nil)
7
+ @save_advance_to_session_id = save_advance_to_session_id
8
+
7
9
  ApplicationRecord.transaction do
8
10
  validate_confirmed_ranks!(voting_session)
9
- advance_confirmed_winners(voting_session) if next_session?(voting_session)
11
+
12
+ if voting_session.save_vote?
13
+ restore_and_advance_saved_contestants(voting_session)
14
+ elsif next_session?(voting_session)
15
+ advance_confirmed_winners(voting_session)
16
+ end
17
+
10
18
  voting_session.update!(status: :finalized)
11
19
  success(voting_session: voting_session)
12
20
  end
@@ -17,7 +25,7 @@ module SpreeCmCommissioner
17
25
  private
18
26
 
19
27
  def validate_confirmed_ranks!(voting_session)
20
- if next_session?(voting_session)
28
+ if voting_session.save_vote? || next_session?(voting_session)
21
29
  raise I18n.t('voting.errors.no_winners_confirmed') if voting_session.voting_contestants.where.not(confirmed_rank: nil).none?
22
30
  else
23
31
  unranked = voting_session.voting_contestants.where(eliminated: false, confirmed_rank: nil)
@@ -25,6 +33,27 @@ module SpreeCmCommissioner
25
33
  end
26
34
  end
27
35
 
36
+ def restore_and_advance_saved_contestants(voting_session)
37
+ target_session = SpreeCmCommissioner::VotingSession.find_by(id: @save_advance_to_session_id)
38
+ raise I18n.t('voting.errors.save_advance_to_session_required') if target_session.nil?
39
+
40
+ voting_session.voting_contestants.each do |vc|
41
+ if vc.confirmed_rank.present?
42
+ vc.show_contestant.update!(status: :active)
43
+ SpreeCmCommissioner::VotingContestants::Advancer.call(
44
+ voting_contestant: vc,
45
+ attributes: { advanced_to: target_session }
46
+ )
47
+ else
48
+ SpreeCmCommissioner::VotingContestants::Advancer.call(
49
+ voting_contestant: vc,
50
+ attributes: { eliminated: true, eliminated_via: :instant_save_lost, advanced_to_type: nil, advanced_to_id: nil }
51
+ )
52
+ # ShowContestant#status already :eliminated — no change needed
53
+ end
54
+ end
55
+ end
56
+
28
57
  # Re-syncs advancement on every call — handles first finalize and re-finalize.
29
58
  # Contestants without a confirmed_rank get their advanced_to cleared.
30
59
  def advance_confirmed_winners(voting_session)
@@ -48,6 +77,7 @@ module SpreeCmCommissioner
48
77
  voting_contestant: voting_contestant,
49
78
  attributes: { eliminated: true, eliminated_via: :public_vote, advanced_to_type: nil, advanced_to_id: nil }
50
79
  )
80
+ voting_contestant.show_contestant.update!(status: :eliminated)
51
81
  end
52
82
  end
53
83
  end
@@ -767,6 +767,8 @@ en:
767
767
  rate_limit_ip: "Too many votes from your network. Please try again shortly."
768
768
  device_account_limit: "This device has been used by too many accounts."
769
769
  vpn_blocked: "Voting is not allowed from VPN or proxy connections."
770
+ contestant_vote_cap_exceeded: "You have reached the maximum number of votes allowed for this contestant."
770
771
  no_winners_confirmed: "No winners confirmed"
771
772
  all_contestants_must_be_ranked: "All non-eliminated contestants must be ranked"
773
+ save_advance_to_session_required: "A target voting session must be selected to advance saved contestants"
772
774
 
@@ -609,4 +609,5 @@ km:
609
609
  errors:
610
610
  no_winners_confirmed: "មិនមានអ្នកឈ្នះត្រូវបានបញ្ជាក់"
611
611
  all_contestants_must_be_ranked: "អ្នកចូលរួមទាំងអស់ដែលមិនត្រូវបានលុបចោលត្រូវតែមានចំណាត់ថ្នាក់"
612
+ save_advance_to_session_required: "ត្រូវតែជ្រើសរើសសម័យបោះឆ្នោតគោលដៅដើម្បីជំរុញអ្នកចូលរួមដែលបានជ្រើស"
612
613
 
@@ -0,0 +1,6 @@
1
+ class AddSessionTypeToCmVotingSessions < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :cm_voting_sessions, :session_type, :integer, default: 0, null: false, if_not_exists: true
4
+ add_index :cm_voting_sessions, :session_type, if_not_exists: true
5
+ end
6
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.8.2.pre.pre.2'.freeze
2
+ VERSION = '2.8.2.pre.pre.3'.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.2.pre.pre.2
4
+ version: 2.8.2.pre.pre.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-31 00:00:00.000000000 Z
11
+ date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -3275,6 +3275,7 @@ files:
3275
3275
  - db/migrate/20260525042257_add_vote_number_to_cm_voting_contestants.rb
3276
3276
  - db/migrate/20260527035430_add_confirmed_rank_to_cm_voting_contestants.rb
3277
3277
  - db/migrate/20260527062005_add_eliminated_at_to_cm_show_contestants.rb
3278
+ - db/migrate/20260529000001_add_session_type_to_cm_voting_sessions.rb
3278
3279
  - docker-compose.yml
3279
3280
  - docs/api/scoped-access-token-endpoints.md
3280
3281
  - docs/option_types/attr_types.md