spree_cm_commissioner 2.8.2.pre.pre.2 → 2.8.2

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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -4
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/homepage_section_controller.rb +1 -4
  5. data/app/controllers/spree/admin/taxons_controller_decorator.rb +0 -19
  6. data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +0 -1
  7. data/app/controllers/spree/api/v2/tenant/base_controller.rb +0 -4
  8. data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +0 -1
  9. data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
  10. data/app/controllers/spree/api/v2/tenant/taxons_controller.rb +1 -1
  11. data/app/controllers/spree_cm_commissioner/admin/products_controller_decorator.rb +0 -19
  12. data/app/finders/spree_cm_commissioner/events/find_matches.rb +0 -1
  13. data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +0 -2
  14. data/app/models/concerns/spree_cm_commissioner/homepage_section_bitwise.rb +1 -2
  15. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +1 -2
  16. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +0 -10
  17. data/app/models/concerns/spree_cm_commissioner/product_type.rb +1 -1
  18. data/app/models/spree_cm_commissioner/product_decorator.rb +0 -39
  19. data/app/models/spree_cm_commissioner/role_decorator.rb +1 -4
  20. data/app/models/spree_cm_commissioner/taxon_decorator.rb +0 -15
  21. data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +1 -10
  22. data/app/models/spree_cm_commissioner/tenant.rb +0 -9
  23. data/app/models/spree_cm_commissioner/user_decorator.rb +0 -5
  24. data/app/models/spree_cm_commissioner/variant_options.rb +0 -4
  25. data/app/models/spree_cm_commissioner/vendor_decorator.rb +0 -4
  26. data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +1 -1
  27. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +1 -1
  28. data/app/serializers/spree/v2/storefront/role_serializer.rb +1 -1
  29. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -2
  30. data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +1 -1
  31. data/app/serializers/spree/v2/tenant/role_serializer.rb +1 -1
  32. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +0 -12
  33. data/app/views/spree/admin/homepage_section/_form.html.erb +0 -5
  34. data/config/initializers/spree_permitted_attributes.rb +0 -8
  35. data/config/locales/en.yml +0 -14
  36. data/config/locales/km.yml +0 -10
  37. data/config/routes.rb +0 -26
  38. data/lib/spree_cm_commissioner/version.rb +1 -1
  39. data/lib/spree_cm_commissioner.rb +1 -7
  40. data/spree_cm_commissioner.gemspec +1 -1
  41. metadata +4 -116
  42. data/app/controllers/spree/api/v2/storefront/preview_products_controller.rb +0 -48
  43. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +0 -27
  44. data/app/controllers/spree/api/v2/storefront/preview_taxons_controller.rb +0 -18
  45. data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +0 -15
  46. data/app/controllers/spree/api/v2/storefront/taxons_controller_decorator.rb +0 -15
  47. data/app/controllers/spree/api/v2/tenant/free_vote_claims_controller.rb +0 -37
  48. data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +0 -47
  49. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +0 -26
  50. data/app/controllers/spree/api/v2/tenant/preview_shows_controller.rb +0 -19
  51. data/app/controllers/spree/api/v2/tenant/preview_taxons_controller.rb +0 -19
  52. data/app/controllers/spree/api/v2/tenant/show_contestants_controller.rb +0 -52
  53. data/app/controllers/spree/api/v2/tenant/show_elimination_sessions_controller.rb +0 -57
  54. data/app/controllers/spree/api/v2/tenant/show_people_controller.rb +0 -49
  55. data/app/controllers/spree/api/v2/tenant/show_person_assignments_controller.rb +0 -36
  56. data/app/controllers/spree/api/v2/tenant/shows_controller.rb +0 -34
  57. data/app/controllers/spree/api/v2/tenant/votes_controller.rb +0 -94
  58. data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +0 -40
  59. data/app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb +0 -41
  60. data/app/controllers/spree/api/v2/tenant/voting_credits_controller.rb +0 -31
  61. data/app/jobs/spree_cm_commissioner/vote_fraud_event_job.rb +0 -9
  62. data/app/jobs/spree_cm_commissioner/voting_credit_allocation_job.rb +0 -10
  63. data/app/jobs/spree_cm_commissioner/voting_credit_de_allocation_job.rb +0 -10
  64. data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +0 -36
  65. data/app/models/spree_cm_commissioner/preview_role.rb +0 -8
  66. data/app/models/spree_cm_commissioner/role_user_decorator.rb +0 -8
  67. data/app/models/spree_cm_commissioner/show.rb +0 -159
  68. data/app/models/spree_cm_commissioner/show_contestant.rb +0 -39
  69. data/app/models/spree_cm_commissioner/show_contestant_image.rb +0 -11
  70. data/app/models/spree_cm_commissioner/show_contestant_video.rb +0 -15
  71. data/app/models/spree_cm_commissioner/show_episode.rb +0 -135
  72. data/app/models/spree_cm_commissioner/show_person.rb +0 -15
  73. data/app/models/spree_cm_commissioner/show_person_assignment.rb +0 -20
  74. data/app/models/spree_cm_commissioner/show_person_image.rb +0 -11
  75. data/app/models/spree_cm_commissioner/vote.rb +0 -16
  76. data/app/models/spree_cm_commissioner/vote_fraud_event.rb +0 -19
  77. data/app/models/spree_cm_commissioner/voting_contestant.rb +0 -46
  78. data/app/models/spree_cm_commissioner/voting_credit.rb +0 -72
  79. data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +0 -55
  80. data/app/models/spree_cm_commissioner/voting_session.rb +0 -223
  81. data/app/models/spree_cm_commissioner/voting_session_stat.rb +0 -8
  82. data/app/overrides/spree/admin/products/_form/preview_checkbox.html.erb.deface +0 -9
  83. data/app/overrides/spree/admin/taxons/_form/preview_checkbox.html.erb.deface +0 -7
  84. data/app/serializers/spree/v2/tenant/show_contestant_serializer.rb +0 -21
  85. data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +0 -17
  86. data/app/serializers/spree/v2/tenant/show_person_assignment_serializer.rb +0 -16
  87. data/app/serializers/spree/v2/tenant/show_person_serializer.rb +0 -13
  88. data/app/serializers/spree/v2/tenant/show_serializer.rb +0 -26
  89. data/app/serializers/spree/v2/tenant/video_serializer.rb +0 -9
  90. data/app/serializers/spree/v2/tenant/vote_serializer.rb +0 -14
  91. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +0 -22
  92. data/app/serializers/spree/v2/tenant/voting_credit_serializer.rb +0 -10
  93. data/app/serializers/spree/v2/tenant/voting_credit_transaction_serializer.rb +0 -14
  94. data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +0 -18
  95. data/app/services/spree_cm_commissioner/fraud_check.rb +0 -279
  96. data/app/services/spree_cm_commissioner/show_contestants/normalize_video_highlights.rb +0 -57
  97. data/app/services/spree_cm_commissioner/url_embed/youtube_embed.rb +0 -44
  98. data/app/services/spree_cm_commissioner/vote_counters/audit_counters.rb +0 -43
  99. data/app/services/spree_cm_commissioner/vote_counters/base.rb +0 -31
  100. data/app/services/spree_cm_commissioner/vote_counters/increment.rb +0 -44
  101. data/app/services/spree_cm_commissioner/vote_counters/per_contestant_counter.rb +0 -68
  102. data/app/services/spree_cm_commissioner/vote_counters/rebuild_from_db.rb +0 -70
  103. data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +0 -113
  104. data/app/services/spree_cm_commissioner/vote_credit_deductor.rb +0 -68
  105. data/app/services/spree_cm_commissioner/vote_package/create.rb +0 -145
  106. data/app/services/spree_cm_commissioner/vote_package/update.rb +0 -91
  107. data/app/services/spree_cm_commissioner/vote_processor.rb +0 -144
  108. data/app/services/spree_cm_commissioner/voting_contestants/advancer.rb +0 -334
  109. data/app/services/spree_cm_commissioner/voting_contestants/assigner.rb +0 -32
  110. data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +0 -106
  111. data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +0 -77
  112. data/app/services/spree_cm_commissioner/voting_credits/claim_free_votes.rb +0 -119
  113. data/app/services/spree_cm_commissioner/voting_credits/credit_calculator.rb +0 -35
  114. data/app/services/spree_cm_commissioner/voting_credits/de_allocate.rb +0 -87
  115. data/app/services/spree_cm_commissioner/voting_leaderboards/calculate_score.rb +0 -74
  116. data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +0 -66
  117. data/db/migrate/20260309230148_create_cm_show_people.rb +0 -14
  118. data/db/migrate/20260309230149_create_cm_show_people_assignments.rb +0 -16
  119. data/db/migrate/20260310082711_create_cm_show_contestants.rb +0 -28
  120. data/db/migrate/20260310082720_create_cm_voting_sessions.rb +0 -21
  121. data/db/migrate/20260310082721_create_cm_voting_contestants.rb +0 -23
  122. data/db/migrate/20260310082734_add_voting_fields_to_spree_taxons.rb +0 -9
  123. data/db/migrate/20260310082735_add_type_to_spree_products.rb +0 -6
  124. data/db/migrate/20260310082749_create_cm_voting_credits.rb +0 -27
  125. data/db/migrate/20260326080200_create_cm_voting_credit_transactions.rb +0 -27
  126. data/db/migrate/20260330160000_create_cm_votes.rb +0 -25
  127. data/db/migrate/20260401072500_add_advanced_from_to_cm_voting_contestants.rb +0 -7
  128. data/db/migrate/20260402000001_add_voting_credit_scope_to_spree_taxons.rb +0 -6
  129. data/db/migrate/20260402000002_rename_scopeable_to_votable_in_cm_voting_credits.rb +0 -12
  130. data/db/migrate/20260403070000_add_name_to_cm_voting_sessions.rb +0 -5
  131. data/db/migrate/20260406000001_add_vendor_id_to_voting_tables.rb +0 -6
  132. data/db/migrate/20260406000001_rename_votes_remaining_to_amount_in_cm_voting_credits.rb +0 -11
  133. data/db/migrate/20260408085255_add_show_id_and_vendor_id_to_cm_voting_sessions.rb +0 -9
  134. data/db/migrate/20260420000001_rename_type_to_credit_type_in_cm_voting_credits.rb +0 -25
  135. data/db/migrate/20260422000001_create_cm_vote_fraud_events.rb +0 -23
  136. data/db/migrate/20260423000001_add_preview_to_taxons_products_and_sections.rb +0 -11
  137. data/db/migrate/20260423000002_create_preview_roles.rb +0 -24
  138. data/db/migrate/20260515120000_add_public_metadata_to_cm_voting_sessions.rb +0 -5
  139. data/db/migrate/20260518090920_add_unique_voter_count_to_voting_contestants.rb +0 -5
  140. data/db/migrate/20260518094322_create_cm_voting_session_stats.rb +0 -17
  141. data/db/migrate/20260520000001_add_scoring_model_to_cm_voting_sessions.rb +0 -5
  142. data/db/migrate/20260520000001_optimize_cm_votes_indexes.rb +0 -22
  143. data/db/migrate/20260525042257_add_vote_number_to_cm_voting_contestants.rb +0 -18
  144. data/db/migrate/20260527035430_add_confirmed_rank_to_cm_voting_contestants.rb +0 -5
  145. data/db/migrate/20260527062005_add_eliminated_at_to_cm_show_contestants.rb +0 -5
  146. data/docs/sql/jsonb_query_guide.md +0 -57
  147. data/lib/spree_cm_commissioner/test_helper/factories/show_episode_factory.rb +0 -12
  148. data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +0 -120
  149. data/lib/spree_cm_commissioner/test_helper/factories/vote_credit_factory.rb +0 -37
  150. data/lib/spree_cm_commissioner/test_helper/factories/vote_factory.rb +0 -28
  151. data/lib/spree_cm_commissioner/test_helper/factories/voting_credit_transaction_factory.rb +0 -11
  152. data/lib/spree_cm_commissioner/test_helper/factories/voting_session_factory.rb +0 -11
@@ -1,279 +0,0 @@
1
- # Real-time fraud detection for vote submissions.
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)
8
-
9
- # On any violation, raises RuntimeError with an I18n user-facing message
10
- # and enqueues a VoteFraudEventJob asynchronously.
11
-
12
- # All Redis operations use SpreeCmCommissioner.voting_redis_pool.
13
- module SpreeCmCommissioner
14
- class FraudCheck
15
- prepend ::Spree::ServiceModule::Base
16
-
17
- DEFAULTS = {
18
- 'max_votes_per_minute_per_user' => 5,
19
- 'max_votes_per_minute_per_ip' => 10,
20
- 'max_accounts_per_device' => 3,
21
- 'block_vpn' => false
22
- }.freeze
23
-
24
- RATE_WINDOW = 60 # seconds
25
- VPN_CACHE_TTL = 3600 # 1 hour
26
-
27
- attr_reader :voting_session, :contestant, :user, :params, :request
28
-
29
- def call(voting_session:, user:, params:, request:, contestant: nil)
30
- @voting_session = voting_session
31
- @contestant = contestant
32
- @user = user
33
- @params = params
34
- @request = request
35
-
36
- return success(nil) if fraud_config.blank?
37
-
38
- check_user_rate_limit!
39
- check_ip_rate_limit!
40
- check_device_account_limit!
41
- check_vpn_block!
42
-
43
- success(nil)
44
- rescue Redis::BaseError, ConnectionPool::TimeoutError => e
45
- # Fail-open: a Redis outage should not take down voting entirely.
46
- # Catches both Redis protocol errors and connection pool exhaustion/timeout.
47
- # Must be rescued BEFORE RuntimeError because Redis::BaseError < RuntimeError
48
- # in redis gem v4.x — placing it after RuntimeError would let the wrong clause win.
49
- # Log the error so it is visible in monitoring, but let the vote through.
50
- # If the policy should be fail-closed instead, replace the rescue body
51
- # with: failure(I18n.t('voting.errors.service_unavailable'))
52
- CmAppLogger.error(
53
- label: 'FraudCheck Redis error — failing open',
54
- data: {
55
- voting_session_id: voting_session.id,
56
- user_id: user&.id,
57
- error_class: e.class.name,
58
- error_message: e.message
59
- }
60
- )
61
- success(nil)
62
- rescue RuntimeError => e
63
- failure(nil, e.message)
64
- end
65
-
66
- private
67
-
68
- def fraud_config
69
- @fraud_config ||= voting_session.effective_fraud_config
70
- end
71
-
72
- def config_value(key)
73
- value = fraud_config.fetch(key, DEFAULTS[key])
74
- default = DEFAULTS[key]
75
- default.is_a?(Integer) ? value.to_i : value
76
- end
77
-
78
- def session_id
79
- voting_session.id
80
- end
81
-
82
- def user_identifier
83
- user&.id || params[:voter_identifier]
84
- end
85
-
86
- def ip_address
87
- request&.remote_ip || params[:ip_address]
88
- end
89
-
90
- def device_fingerprint
91
- params[:device_fingerprint]
92
- end
93
-
94
- def quantity
95
- [params[:quantity].to_i, 1].max
96
- end
97
-
98
- # --- Check 1: User rate limit ---
99
-
100
- def check_user_rate_limit!
101
- threshold = config_value('max_votes_per_minute_per_user')
102
- return if threshold.nil? || threshold <= 0
103
-
104
- key = "fraud:rate:user:#{session_id}:#{user_identifier}"
105
- count = increment_with_expiry(key, RATE_WINDOW, by: quantity)
106
-
107
- return unless count > threshold
108
-
109
- log_fraud_event(:rate_limit, :medium)
110
- raise I18n.t('voting.errors.rate_limit_user')
111
- end
112
-
113
- # --- Check 2: IP rate limit ---
114
-
115
- def check_ip_rate_limit!
116
- return if ip_address.blank?
117
-
118
- threshold = config_value('max_votes_per_minute_per_ip')
119
- return if threshold.nil? || threshold <= 0
120
-
121
- key = "fraud:rate:ip:#{session_id}:#{ip_address}"
122
- count = increment_with_expiry(key, RATE_WINDOW, by: quantity)
123
-
124
- return unless count > threshold
125
-
126
- log_fraud_event(:ip_cluster, :medium)
127
- raise I18n.t('voting.errors.rate_limit_ip')
128
- end
129
-
130
- # --- Check 3: Device account limit ---
131
-
132
- def check_device_account_limit!
133
- return if device_fingerprint.blank?
134
-
135
- threshold = config_value('max_accounts_per_device')
136
- return if threshold.nil? || threshold <= 0
137
-
138
- key = "fraud:device:#{session_id}:#{device_fingerprint}"
139
- member = user_identifier.to_s
140
-
141
- card = sadd_with_expiry(key, member, session_ttl)
142
-
143
- return unless card > threshold
144
-
145
- log_fraud_event(:device_duplicate, :high)
146
- raise I18n.t('voting.errors.device_account_limit')
147
- end
148
-
149
- # --- Check 4: VPN / proxy block ---
150
-
151
- def check_vpn_block!
152
- return unless config_value('block_vpn')
153
- return if ip_address.blank?
154
-
155
- return unless vpn_detected?(ip_address)
156
-
157
- log_fraud_event(:anomaly, :high)
158
- raise I18n.t('voting.errors.vpn_blocked')
159
- end
160
-
161
- # --- Redis helpers ---
162
-
163
- # Atomically increments a counter key and sets its TTL on the first write.
164
- #
165
- # Why Lua: issuing INCRBY then EXPIRE as two separate commands creates a race
166
- # where another request can read the key between the two commands, or the TTL
167
- # can be set more than once (resetting the window) if two requests race on the
168
- # first write. Running both commands inside a Lua script executes them as a
169
- # single atomic unit in Redis, eliminating the race entirely.
170
- #
171
- # KEYS[1] – the Redis key to increment
172
- # ARGV[1] – increment amount (quantity)
173
- # ARGV[2] – TTL in seconds for the rate window
174
- #
175
- # Returns the new count after incrementing.
176
- INCRBY_WITH_EXPIRY_SCRIPT = <<~LUA.freeze
177
- local count = redis.call('INCRBY', KEYS[1], ARGV[1])
178
- if count == tonumber(ARGV[1]) then
179
- redis.call('EXPIRE', KEYS[1], ARGV[2])
180
- end
181
- return count
182
- LUA
183
-
184
- # Atomically adds a member to a set, sets TTL on the first write, and
185
- # returns the set cardinality.
186
- #
187
- # Why Lua: SADD, EXPIRE, and SCARD as separate commands can interleave with
188
- # other requests — a concurrent SADD could reset the TTL unintentionally, or
189
- # SCARD could be read before EXPIRE is applied. The Lua script runs all three
190
- # atomically, guaranteeing a consistent view of the set on every call.
191
- #
192
- # KEYS[1] – the Redis set key
193
- # ARGV[1] – the member to add (user identifier)
194
- # ARGV[2] – TTL in seconds; skipped when 0
195
- #
196
- # Returns the number of distinct members in the set after the add.
197
- SADD_WITH_EXPIRY_SCRIPT = <<~LUA.freeze
198
- redis.call('SADD', KEYS[1], ARGV[1])
199
- if redis.call('TTL', KEYS[1]) == -1 and tonumber(ARGV[2]) > 0 then
200
- redis.call('EXPIRE', KEYS[1], ARGV[2])
201
- end
202
- return redis.call('SCARD', KEYS[1])
203
- LUA
204
-
205
- def increment_with_expiry(key, ttl, by: 1)
206
- with_redis { |r| r.eval(INCRBY_WITH_EXPIRY_SCRIPT, keys: [key], argv: [by, ttl]) }
207
- end
208
-
209
- def sadd_with_expiry(key, member, ttl)
210
- with_redis { |r| r.eval(SADD_WITH_EXPIRY_SCRIPT, keys: [key], argv: [member, ttl]) }
211
- end
212
-
213
- def with_redis(&block)
214
- SpreeCmCommissioner.voting_redis_pool.with(&block)
215
- end
216
-
217
- def session_ttl
218
- return RATE_WINDOW unless voting_session.closes_at
219
-
220
- remaining = (voting_session.closes_at - Time.current).to_i
221
- [remaining, RATE_WINDOW].max
222
- end
223
-
224
- # --- VPN detection ---
225
- # Checks a Redis cache first (TTL = VPN_CACHE_TTL). If no cached result,
226
- # delegates to lookup_vpn and caches the outcome.
227
- #
228
- # Cache values: '1' = VPN/proxy detected, '0' = clean.
229
- # This avoids hitting the external provider on every vote request.
230
- #
231
- # To manually mark an IP as a VPN for testing:
232
- # SpreeCmCommissioner.voting_redis_pool.with { |r| r.setex("fraud:vpn:<ip>", 3600, '1') }
233
-
234
- def vpn_detected?(ip)
235
- cache_key = "fraud:vpn:#{ip}"
236
-
237
- cached = with_redis { |r| r.get(cache_key) }
238
- return cached == '1' unless cached.nil?
239
-
240
- result = lookup_vpn(ip)
241
- with_redis { |r| r.setex(cache_key, VPN_CACHE_TTL, result ? '1' : '0') }
242
- result
243
- end
244
-
245
- # Placeholder for external VPN/proxy detection. Currently always returns
246
- # false, making block_vpn a no-op unless Redis is pre-seeded manually.
247
- #
248
- # To activate, replace this method body with a real provider call, e.g.:
249
- # - ip-api.com → HTTP GET http://ip-api.com/json/<ip>?fields=proxy,hosting
250
- # - MaxMind → local GeoIP2 DB lookup (no network call, best for high traffic)
251
- # - Blocklist → check against a maintained list of known VPN/datacenter CIDRs
252
- #
253
- # The Redis caching in vpn_detected? is already in place, so the provider
254
- # is only called once per unique IP per VPN_CACHE_TTL window.
255
- def lookup_vpn(_ip)
256
- false
257
- end
258
-
259
- # --- Fraud event logging (async) ---
260
-
261
- def log_fraud_event(fraud_type, severity)
262
- SpreeCmCommissioner::VoteFraudEventJob.perform_later(
263
- tenant_id: voting_session.vendor.tenant_id,
264
- user_id: user&.id,
265
- voting_session_id: voting_session.id,
266
- fraud_type: fraud_type,
267
- severity: severity,
268
- action_taken: :blocked,
269
- details: {
270
- ip_address: ip_address,
271
- device_fingerprint: device_fingerprint,
272
- voter_identifier: params[:voter_identifier],
273
- contestant_id: contestant&.id,
274
- contestant_name: contestant&.name
275
- }
276
- )
277
- end
278
- end
279
- end
@@ -1,57 +0,0 @@
1
- module SpreeCmCommissioner
2
- module ShowContestants
3
- class NormalizeVideoHighlights
4
- prepend ::Spree::ServiceModule::Base
5
-
6
- def call(raw_video_highlights: nil)
7
- return success(video_highlights: []) if raw_video_highlights.blank?
8
-
9
- video_highlights = normalize_video_highlights(raw_video_highlights: raw_video_highlights)
10
-
11
- success(video_highlights: video_highlights)
12
- rescue StandardError => e
13
- failure(nil, e.message)
14
- end
15
-
16
- private
17
-
18
- def normalize_video_highlights(raw_video_highlights:)
19
- return [] if raw_video_highlights.blank?
20
-
21
- source = normalize_source(raw_video_highlights)
22
- normalized = source.map { |payload| normalize_video(payload) }
23
- normalized.reject { |video| video_empty?(video) }
24
- end
25
-
26
- def normalize_source(raw_video_highlights)
27
- return raw_video_highlights if raw_video_highlights.is_a?(Array)
28
-
29
- raw_video_highlights.to_h.sort_by { |index, _| index.to_i }.map(&:last)
30
- end
31
-
32
- def normalize_video(payload)
33
- payload_hash = payload.to_h
34
- uri = extract_value(payload_hash, 'uri').to_s.strip
35
-
36
- {
37
- 'title' => extract_value(payload_hash, 'title').to_s.strip,
38
- 'description' => extract_value(payload_hash, 'description').to_s.strip,
39
- 'uri' => uri.present? ? embed_youtube_url(uri) : uri
40
- }
41
- end
42
-
43
- def extract_value(hash, key)
44
- hash[key] || hash[key.to_sym]
45
- end
46
-
47
- def embed_youtube_url(uri)
48
- result = UrlEmbed::YoutubeEmbed.call(url: uri)
49
- result.success? ? result.value : uri
50
- end
51
-
52
- def video_empty?(video)
53
- video&.dig('title').blank? && video&.dig('description').blank? && video&.dig('uri').blank?
54
- end
55
- end
56
- end
57
- end
@@ -1,44 +0,0 @@
1
- # Converts various YouTube URL formats to embeddable iframe URLs.
2
- #
3
- # Examples:
4
- # result = YoutubeEmbed.call(url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
5
- # result.success? # => true
6
- # result.value # => "https://www.youtube.com/embed/dQw4w9WgXcQ"
7
- #
8
- # result = YoutubeEmbed.call(url: "https://youtu.be/dQw4w9WgXcQ")
9
- # result.success? # => true
10
- module SpreeCmCommissioner
11
- module UrlEmbed
12
- class YoutubeEmbed
13
- prepend ::Spree::ServiceModule::Base
14
-
15
- def call(url:)
16
- return failure(nil, 'URL is blank') if url.blank?
17
-
18
- video_id = self.class.extract_video_id(url.to_s.strip)
19
- return failure(nil, 'Invalid YouTube URL') unless video_id
20
-
21
- success("https://www.youtube.com/embed/#{video_id}")
22
- end
23
-
24
- # Extracts video ID from YouTube URL (used for thumbnails, etc.)
25
- def self.extract_video_id(url_string)
26
- return nil if url_string.blank?
27
-
28
- PATTERNS.lazy.map { |pattern| url_string[pattern, 1] }.find(&:present?)
29
- end
30
-
31
- # Regex patterns to extract video IDs from various YouTube URL formats
32
- # Supports: watch?v=, youtu.be, embed, shorts, live, and /v/ URLs
33
- # All patterns capture alphanumeric characters, underscores, and hyphens as video IDs
34
- PATTERNS = [
35
- %r{youtube\.com/watch\?v=([a-zA-Z0-9_-]+)}, # Standard watch URL
36
- %r{youtu\.be/([a-zA-Z0-9_-]+)}, # Shortened youtu.be URL
37
- %r{youtube\.com/embed/([a-zA-Z0-9_-]+)}, # Already embedded URL
38
- %r{youtube\.com/shorts/([a-zA-Z0-9_-]+)}, # YouTube Shorts
39
- %r{youtube\.com/live/([a-zA-Z0-9_-]+)}, # YouTube Live
40
- %r{youtube\.com/v/([a-zA-Z0-9_-]+)} # Legacy /v/ format
41
- ].freeze
42
- end
43
- end
44
- end
@@ -1,43 +0,0 @@
1
- # Compares Redis vote counters against cm_votes for a given voting session.
2
- #
3
- # Logs a mismatch via CmAppLogger when Redis and DB counts diverge.
4
- # Use for post-session auditing or on-demand reconciliation.
5
- #
6
- # Usage:
7
- # SpreeCmCommissioner::VoteCounters::AuditCounters.call(voting_session_id: id)
8
- module SpreeCmCommissioner
9
- module VoteCounters
10
- class AuditCounters < Base
11
- prepend ::Spree::ServiceModule::Base
12
-
13
- def call(voting_session_id:)
14
- @voting_session_id = voting_session_id
15
-
16
- db_counts = SpreeCmCommissioner::Vote.for_session(voting_session_id)
17
- .group(:contestant_id)
18
- .sum(:quantity)
19
- per_contestant_result = SpreeCmCommissioner::VoteCounters::PerContestantCounter
20
- .call(voting_session_id: voting_session_id)
21
- return failure(nil, per_contestant_result.error.value) if per_contestant_result.failure?
22
-
23
- redis_counts = per_contestant_result.value[:counts]
24
-
25
- redis_counts.each do |contestant_id, redis_count|
26
- db_count = db_counts[contestant_id] || 0
27
- next if redis_count == db_count
28
-
29
- CmAppLogger.log(
30
- label: 'VoteCounter mismatch',
31
- data: {
32
- voting_session_id: voting_session_id,
33
- contestant_id: contestant_id,
34
- redis_count: redis_count,
35
- db_count: db_count
36
- }
37
- )
38
- end
39
- success(nil)
40
- end
41
- end
42
- end
43
- end
@@ -1,31 +0,0 @@
1
- module SpreeCmCommissioner
2
- module VoteCounters
3
- class Base
4
- REDIS_KEY_TTL = 86_400 # 24 hours
5
-
6
- attr_reader :voting_session_id
7
-
8
- private
9
-
10
- def vote_key(contestant_id)
11
- "vote:session:#{voting_session_id}:contestant:#{contestant_id}"
12
- end
13
-
14
- def contestant_ids
15
- @contestant_ids ||= voting_session.voting_contestants.pluck(:id)
16
- end
17
-
18
- def voting_session
19
- @voting_session ||= SpreeCmCommissioner::VotingSession.find(voting_session_id)
20
- end
21
-
22
- def session_ttl
23
- [(voting_session.closes_at - Time.current).to_i + 1.day.to_i, 1].max
24
- end
25
-
26
- def with_redis(&block)
27
- SpreeCmCommissioner.voting_redis_pool.with(&block)
28
- end
29
- end
30
- end
31
- end
@@ -1,44 +0,0 @@
1
- # Atomically increments a contestant's Redis vote counter.
2
- #
3
- # Uses a Lua script so INCRBY+EXPIRE is a single atomic round trip.
4
- # Unique voter counts are read from cm_votes table, not tracked here.
5
- #
6
- # Usage:
7
- # SpreeCmCommissioner::VoteCounters::Increment.call(
8
- # voting_session_id: id,
9
- # contestant_id: id,
10
- # quantity: 3
11
- # )
12
- module SpreeCmCommissioner
13
- module VoteCounters
14
- class Increment < Base
15
- prepend ::Spree::ServiceModule::Base
16
-
17
- def call(voting_session_id:, contestant_id:, quantity: 1)
18
- @voting_session_id = voting_session_id
19
-
20
- with_redis do |redis|
21
- redis.eval(
22
- increment_script,
23
- keys: [vote_key(contestant_id)],
24
- argv: [quantity, session_ttl]
25
- )
26
- end
27
-
28
- success(nil)
29
- end
30
-
31
- private
32
-
33
- # INCRBY + EXPIRE in one atomic round trip.
34
- # TTL is closes_at + 1 day so the key outlives the session and cleans up naturally.
35
- def increment_script
36
- <<~LUA
37
- local new_count = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
38
- redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
39
- return new_count
40
- LUA
41
- end
42
- end
43
- end
44
- end
@@ -1,68 +0,0 @@
1
- # Fetches vote counts from Redis and unique voter counts from the database.
2
- #
3
- # Returns a Result whose value is:
4
- # {
5
- # counts: { contestant_id => Integer },
6
- # unique_voters: Integer,
7
- # unique_voters_per_contestant: { contestant_id => Integer }
8
- # }
9
- #
10
- # Vote counts come from Redis (fast, real-time leaderboard).
11
- # Unique voter counts come from cm_votes (accurate, no Redis set overhead).
12
- #
13
- # Usage:
14
- # SpreeCmCommissioner::VoteCounters::PerContestantCounter.call(voting_session_id: id).value
15
- module SpreeCmCommissioner
16
- module VoteCounters
17
- class PerContestantCounter < Base
18
- prepend ::Spree::ServiceModule::Base
19
-
20
- EMPTY_RESULT = {
21
- counts: {}.freeze,
22
- unique_voters: 0,
23
- unique_voters_per_contestant: {}.freeze
24
- }.freeze
25
-
26
- def call(voting_session_id:)
27
- @voting_session_id = voting_session_id
28
-
29
- ids = contestant_ids
30
- return success(EMPTY_RESULT) if ids.empty?
31
-
32
- counts = with_redis do |redis|
33
- values = redis.mget(*ids.map { |id| vote_key(id) })
34
- ids.zip(values).to_h { |id, v| [id, v.to_i] }
35
- end
36
-
37
- success(
38
- counts: counts,
39
- unique_voters: session_unique_voter_count,
40
- unique_voters_per_contestant: contestant_unique_voter_counts(ids)
41
- )
42
- rescue StandardError => e
43
- failure(nil, e.message)
44
- end
45
-
46
- private
47
-
48
- def session_unique_voter_count
49
- SpreeCmCommissioner::Vote
50
- .where(voting_session_id: voting_session_id)
51
- .where.not(user_id: nil)
52
- .distinct
53
- .count(:user_id)
54
- end
55
-
56
- def contestant_unique_voter_counts(ids)
57
- rows = SpreeCmCommissioner::Vote
58
- .where(voting_session_id: voting_session_id, contestant_id: ids)
59
- .where.not(user_id: nil)
60
- .group(:contestant_id)
61
- .distinct
62
- .count(:user_id)
63
-
64
- ids.index_with { |id| rows.fetch(id, 0) }
65
- end
66
- end
67
- end
68
- end
@@ -1,70 +0,0 @@
1
- # Rebuilds Redis vote counters from cm_votes for a given voting session.
2
- #
3
- # Overwrites existing Redis keys with DB totals — use to recover after
4
- # a Redis restart or counter drift.
5
- #
6
- # Usage:
7
- # SpreeCmCommissioner::VoteCounters::RebuildFromDb.call(voting_session_id: id)
8
- module SpreeCmCommissioner
9
- module VoteCounters
10
- class RebuildFromDb < Base
11
- prepend ::Spree::ServiceModule::Base
12
-
13
- def call(voting_session_id:, ttl: nil)
14
- @voting_session_id = voting_session_id
15
- @ttl = ttl
16
-
17
- rebuild_counts
18
-
19
- success(nil)
20
- end
21
-
22
- private
23
-
24
- # Rebuilds vote count keys for all contestants in the session.
25
- #
26
- # Queries VotingContestant (not Vote) so contestants with 0 DB votes are
27
- # included — their stale Redis keys get DELed instead of left behind.
28
- # Uses a single Lua EVAL so the entire operation is atomic: concurrent
29
- # Increment calls cannot interleave between the DEL and SET commands.
30
- def rebuild_counts
31
- all_ids = SpreeCmCommissioner::VotingContestant
32
- .where(voting_session_id: @voting_session_id)
33
- .pluck(:id)
34
- return if all_ids.empty?
35
-
36
- db_counts = SpreeCmCommissioner::Vote.for_session(@voting_session_id)
37
- .group(:contestant_id)
38
- .sum(:quantity)
39
-
40
- keys = all_ids.map { |id| vote_key(id) }
41
- counts = all_ids.map { |id| db_counts[id].to_i }
42
-
43
- with_redis do |redis|
44
- redis.eval(rebuild_counts_script, keys: keys, argv: counts + [@ttl])
45
- end
46
- end
47
-
48
- # Atomically writes DB vote counts to Redis for all contestants.
49
- # SET count + TTL for non-zero counts; DEL for zero-count contestants
50
- # so stale keys from deleted votes are fully cleared.
51
- def rebuild_counts_script
52
- <<~LUA
53
- local ttl = tonumber(ARGV[#ARGV])
54
- for i, key in ipairs(KEYS) do
55
- local count = tonumber(ARGV[i])
56
- if count > 0 then
57
- if ttl then
58
- redis.call('SET', key, count, 'EX', ttl)
59
- else
60
- redis.call('SET', key, count)
61
- end
62
- else
63
- redis.call('DEL', key)
64
- end
65
- end
66
- LUA
67
- end
68
- end
69
- end
70
- end