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 +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +4 -1
- data/app/models/spree_cm_commissioner/show.rb +2 -1
- data/app/models/spree_cm_commissioner/vote_fraud_event.rb +2 -1
- data/app/models/spree_cm_commissioner/voting_contestant.rb +8 -0
- data/app/models/spree_cm_commissioner/voting_session.rb +9 -3
- data/app/serializers/spree/v2/tenant/show_serializer.rb +3 -0
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +4 -3
- data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +2 -2
- data/app/services/spree_cm_commissioner/fraud_check.rb +26 -6
- data/app/services/spree_cm_commissioner/oauth_access_tokens/cleanup_expired.rb +13 -7
- data/app/services/spree_cm_commissioner/voting_contestants/assigner.rb +5 -0
- data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +33 -3
- data/config/locales/en.yml +2 -0
- data/config/locales/km.yml +1 -0
- data/db/migrate/20260529000001_add_session_type_to_cm_voting_sessions.rb +6 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d81921f1e2c71c3740ce4b4d0c933c5ecc1c1c1e008eacf01636e95c2d0078e3
|
|
4
|
+
data.tar.gz: 4b02d2667e7d5a441e37315ecb0c91de142b529a595ea14a350863e75b22bd0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 480b051b7f31a87059d9c6f396e6a6e0c2c48b0a38432c7c93d027cbf038263a500945afdb2c8d9c6dbbed92a2cc857a03eaff25bdb57f31b6e7f429dce6556f
|
|
7
|
+
data.tar.gz: bc516685858c51491db8801c3004f2f8c095c1b2727958877e911312dcb7039feccc8c318c9d88ff8b1c56f88c933478bd05a1664d480dd4a2e0c5dca0a64190
|
data/Gemfile.lock
CHANGED
|
@@ -9,7 +9,10 @@ module Spree
|
|
|
9
9
|
def collection
|
|
10
10
|
@collection ||= @voting_session
|
|
11
11
|
.voting_contestants
|
|
12
|
-
.includes(
|
|
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
|
|
|
@@ -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 }
|
|
@@ -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
|
|
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 :
|
|
8
|
-
:
|
|
9
|
-
:
|
|
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 :
|
|
6
|
-
:
|
|
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
|
|
4
|
-
# 1. User rate limit
|
|
5
|
-
# 2. IP rate limit
|
|
6
|
-
# 3. Device account limit
|
|
7
|
-
# 4. VPN / proxy block
|
|
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 =
|
|
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
|
-
|
|
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('
|
|
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,
|
|
21
|
-
success(total_deleted: total_deleted,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
|
data/config/locales/km.yml
CHANGED
|
@@ -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
|
|
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.
|
|
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-
|
|
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
|