Skip to content

Commit a8d0ff5

Browse files
committed
refactor: extract MatchFilterQuery, cache invalidation, and security audit fixes
- Extract match filters/sorting to MatchFilterQuery (app/queries/) - Add invalidate_cache helper to Cacheable concern - Add after_action cache invalidation on update/destroy in matches, players, tournaments controllers - Move paginate inside cache block in MatchesController to avoid unnecessary query on cache hit - Fix ScoutingPlayersController N+1: replace global includes with scoped org query after pagination - Standardize 6 analytics controllers with before_action :set_player - Decompose CompetitiveController#build_role_performance into 3 helpers, remove rubocop:disable - Move PERFORMANCE_ROLES constant before private section - Fix Semgrep nosemgrep placement in 3 email templates (password_reset x2, welcome) - Update README and PRD with 2026-04-21 security audit results (Brakeman 0, Semgrep 0, pentest 0 real findings
1 parent 30f48cb commit a8d0ff5

17 files changed

Lines changed: 189 additions & 112 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ All cached responses include `X-Cache-Hit: true/false` header.
10131013

10141014
### Security Status
10151015

1016-
**Last Audit**: 2026-03-11
1016+
**Last Audit**: 2026-04-21
10171017
**Overall Grade**: A (all application security tests passing)
10181018
**Status**: Production-ready
10191019

app/controllers/concerns/cacheable.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def cache_response(key, expires_in: 5.minutes, &block)
4242
Rails.cache.fetch(cache_key, expires_in: expires_in, &block)
4343
end
4444

45+
# Deletes one or more org-scoped cache keys.
46+
# Use in after_action callbacks on mutating actions.
47+
#
48+
# @param keys [Array<String>] keys to invalidate (same identifiers passed to cache_response)
49+
def invalidate_cache(*keys)
50+
keys.each { |key| Rails.cache.delete(build_cache_key(key)) }
51+
end
52+
4553
private
4654

4755
# Builds an organisation-scoped cache key to prevent cross-tenant leakage.

app/modules/analytics/controllers/champions_controller.rb

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,24 @@ module Controllers
1717
# Main endpoints:
1818
# - GET show: Returns comprehensive champion statistics including mastery grades and diversity metrics
1919
class ChampionsController < Api::V1::BaseController
20+
before_action :set_player, only: %i[show details]
21+
2022
def show
21-
player = organization_scoped(Player).find(params[:player_id])
22-
stats = fetch_champion_stats(player)
23+
stats = fetch_champion_stats(@player)
2324
champion_stats = build_champion_stats(stats)
2425

25-
render_success(build_champion_data(player, champion_stats))
26+
render_success(build_champion_data(@player, champion_stats))
2627
end
2728

2829
def details
29-
player = organization_scoped(Player).find(params[:player_id])
3030
champion = params[:champion]
3131

3232
if champion.blank?
3333
return render_error(message: 'Champion name is required', code: 'CHAMPION_REQUIRED',
3434
status: :bad_request)
3535
end
3636

37-
matches = fetch_champion_matches(player, champion)
37+
matches = fetch_champion_matches(@player, champion)
3838

3939
if matches.empty?
4040
return render_error(message: "No matches found for champion #{champion}", code: 'NO_MATCHES',
@@ -45,14 +45,12 @@ def details
4545
matches_array = matches.to_a
4646

4747
render_success({
48-
player: PlayerSerializer.render_as_hash(player),
48+
player: PlayerSerializer.render_as_hash(@player),
4949
champion: champion,
5050
icon_url: riot_service.champion_icon_url(champion),
5151
aggregate_stats: build_aggregate_stats(matches, matches_array),
5252
matches: serialize_champion_matches(matches_array, riot_service)
5353
})
54-
rescue ActiveRecord::RecordNotFound
55-
render_error(message: 'Player not found', code: 'PLAYER_NOT_FOUND', status: :not_found)
5654
rescue StandardError => e
5755
Rails.logger.error("Error in champions#details: #{e.message}")
5856
Rails.logger.error(e.backtrace.join("\n"))
@@ -254,6 +252,10 @@ def round_or_default(value, precision, default = 0)
254252
value&.round(precision) || default
255253
end
256254

255+
def set_player
256+
@player = organization_scoped(Player).find(params[:player_id])
257+
end
258+
257259
def build_champion_data(player, champion_stats)
258260
{
259261
player: PlayerSerializer.render_as_hash(player),

app/modules/analytics/controllers/competitive_controller.rb

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def opponents
8484
status: :internal_server_error)
8585
end
8686

87+
PERFORMANCE_ROLES = %w[top jungle mid adc support].freeze
88+
8789
# ── Private helpers ────────────────────────────────────────────
8890
private
8991

@@ -165,38 +167,41 @@ def build_side_performance(rows)
165167
end
166168
end
167169

168-
def build_role_performance(rows) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
169-
roles = %w[top jungle mid adc support]
170-
role_stats = roles.each_with_object({}) do |r, h|
171-
h[r] = { games: 0, wins: 0, champions: Hash.new(0) }
172-
end
170+
def build_role_performance(rows)
171+
role_stats = initial_role_stats
172+
rows.each { |match| accumulate_match_picks(role_stats, match) }
173+
role_stats.map { |role, stats| format_role_stat(role, stats) }
174+
end
173175

174-
rows.each do |match|
175-
won = match.victory
176-
(match.our_picks || []).each do |pick|
177-
role = pick['role']&.downcase
178-
champ = pick['champion']
179-
next unless role_stats.key?(role) && champ.present?
176+
def initial_role_stats
177+
PERFORMANCE_ROLES.each_with_object({}) { |r, h| h[r] = { games: 0, wins: 0, champions: Hash.new(0) } }
178+
end
180179

181-
role_stats[role][:games] += 1
182-
role_stats[role][:wins] += 1 if won
183-
role_stats[role][:champions][champ] += 1
184-
end
185-
end
180+
def accumulate_match_picks(role_stats, match)
181+
won = match.victory
182+
(match.our_picks || []).each do |pick|
183+
role = pick['role']&.downcase
184+
champ = pick['champion']
185+
next unless role_stats.key?(role) && champ.present?
186186

187-
role_stats.map do |role, s|
188-
most_played = s[:champions].max_by { |_, c| c }&.first || 'N/A'
189-
{
190-
role: role,
191-
games: s[:games],
192-
wins: s[:wins],
193-
win_rate: s[:games].positive? ? (s[:wins].to_f / s[:games] * 100).round(1) : 0,
194-
most_played_champion: most_played,
195-
champion_pool_size: s[:champions].size
196-
}
187+
role_stats[role][:games] += 1
188+
role_stats[role][:wins] += 1 if won
189+
role_stats[role][:champions][champ] += 1
197190
end
198191
end
199192

193+
def format_role_stat(role, stats)
194+
most_played = stats[:champions].max_by { |_, c| c }&.first || 'N/A'
195+
{
196+
role: role,
197+
games: stats[:games],
198+
wins: stats[:wins],
199+
win_rate: stats[:games].positive? ? (stats[:wins].to_f / stats[:games] * 100).round(1) : 0,
200+
most_played_champion: most_played,
201+
champion_pool_size: stats[:champions].size
202+
}
203+
end
204+
200205
def extract_meta_champions(matches)
201206
matches.where.not(meta_champions: nil)
202207
.pluck(:meta_champions)

app/modules/analytics/controllers/kda_trend_controller.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,20 @@ module Controllers
1616
# Main endpoints:
1717
# - GET show: Returns KDA trends for the last 50 matches with rolling averages
1818
class KdaTrendController < Api::V1::BaseController
19-
def show
20-
player = organization_scoped(Player).find(params[:player_id])
19+
before_action :set_player, only: %i[show]
2120

21+
def show
2222
# Get recent matches for the player
2323
stats = PlayerMatchStat.joins(:match)
24-
.where(player: player, matches: { organization_id: current_organization.id })
24+
.where(player: @player, matches: { organization_id: current_organization.id })
2525
.order('matches.game_start DESC')
2626
.limit(50)
2727
.includes(:match)
2828

2929
stats_array = stats.to_a
3030

3131
trend_data = {
32-
player: PlayerSerializer.render_as_hash(player),
32+
player: PlayerSerializer.render_as_hash(@player),
3333
kda_by_match: stats_array.map do |stat|
3434
kda = if stat.deaths.zero?
3535
(stat.kills + stat.assists).to_f
@@ -59,6 +59,10 @@ def show
5959

6060
private
6161

62+
def set_player
63+
@player = organization_scoped(Player).find(params[:player_id])
64+
end
65+
6266
def calculate_kda_average(stats)
6367
return 0 if stats.empty?
6468

app/modules/analytics/controllers/laning_controller.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@ module Controllers
99
# so those fields are omitted (nil) and the frontend falls back gracefully.
1010
#
1111
class LaningController < Api::V1::BaseController
12-
def show
13-
player = organization_scoped(Player).find(params[:player_id])
12+
before_action :set_player, only: %i[show]
1413

14+
def show
1515
stats = PlayerMatchStat.joins(:match)
1616
.includes(:match)
17-
.where(player: player, match: { organization: current_organization })
17+
.where(player: @player, match: { organization: current_organization })
1818
.order('"match"."game_start" DESC')
1919
.limit(20)
2020

2121
games = stats.count
2222
wins = stats.where(match: { victory: true }).count
2323

2424
laning_data = {
25-
player: PlayerSerializer.render_as_hash(player),
25+
player: PlayerSerializer.render_as_hash(@player),
2626
avg_cs_per_min: stats.average(:cs_per_min)&.round(1) || calculate_avg_cs_per_min(stats),
2727
avg_cs_total: stats.average(:cs)&.round(1) || 0,
2828
lane_win_rate: games.zero? ? nil : ((wins.to_f / games) * 100).round(1),
@@ -43,6 +43,10 @@ def show
4343

4444
private
4545

46+
def set_player
47+
@player = organization_scoped(Player).find(params[:player_id])
48+
end
49+
4650
def build_laning_trend(stats)
4751
stats.map do |stat|
4852
next unless stat.match.game_start

app/modules/analytics/controllers/ping_profile_controller.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,24 @@ module Controllers
1111
# GET /api/v1/analytics/players/:player_id/ping-profile
1212
# GET /api/v1/analytics/players/:player_id/ping-profile?games=30
1313
class PingProfileController < Api::V1::BaseController
14+
before_action :set_player, only: %i[show]
15+
1416
def show
15-
player = organization_scoped(Player).find(params[:player_id])
1617
games = [params.fetch(:games, 20).to_i, 50].min
1718

18-
profile = PingProfileService.new(player, matches_limit: games).calculate
19+
profile = PingProfileService.new(@player, matches_limit: games).calculate
1920

2021
render_success({
21-
player: PlayerSerializer.render_as_hash(player),
22+
player: PlayerSerializer.render_as_hash(@player),
2223
ping_profile: profile
2324
})
2425
end
26+
27+
private
28+
29+
def set_player
30+
@player = organization_scoped(Player).find(params[:player_id])
31+
end
2532
end
2633
end
2734
end

app/modules/analytics/controllers/teamfights_controller.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ module Controllers
1616
# Main endpoints:
1717
# - GET show: Returns teamfight statistics for the last 20 matches including damage and multikills
1818
class TeamfightsController < Api::V1::BaseController
19-
def show
20-
player = organization_scoped(Player).find(params[:player_id])
19+
before_action :set_player, only: %i[show]
2120

21+
def show
2222
stats = PlayerMatchStat.joins(:match)
23-
.where(player: player)
23+
.where(player: @player)
2424
.where('matches.organization_id = ?', current_organization.id)
2525
.order('matches.game_start DESC')
2626
.preload(:match)
2727
.limit(20)
2828

2929
teamfight_data = {
30-
player: PlayerSerializer.render_as_hash(player),
30+
player: PlayerSerializer.render_as_hash(@player),
3131
damage_performance: {
3232
avg_damage_dealt: stats.average(:damage_dealt_total)&.round(0),
3333
avg_damage_taken: stats.average(:damage_taken)&.round(0),
@@ -68,6 +68,10 @@ def show
6868

6969
private
7070

71+
def set_player
72+
@player = organization_scoped(Player).find(params[:player_id])
73+
end
74+
7175
def calculate_avg_damage_per_min(stats)
7276
total_damage = 0
7377
total_minutes = 0

app/modules/analytics/controllers/vision_controller.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ module Controllers
88
# without unpacking nested keys.
99
#
1010
class VisionController < Api::V1::BaseController
11-
def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
12-
player = organization_scoped(Player).find(params[:player_id])
11+
before_action :set_player, only: %i[show]
1312

13+
def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1414
stats = PlayerMatchStat.joins(:match)
1515
.includes(:match)
16-
.where(player: player, match: { organization: current_organization })
16+
.where(player: @player, match: { organization: current_organization })
1717
.order('"match"."game_start" DESC')
1818
.limit(20)
1919

2020
vision_data = {
21-
player: PlayerSerializer.render_as_hash(player),
21+
player: PlayerSerializer.render_as_hash(@player),
2222
avg_vision_score: stats.average(:vision_score)&.round(1) || 0,
2323
avg_wards_placed: stats.average(:wards_placed)&.round(1) || 0,
2424
avg_wards_destroyed: stats.average(:wards_destroyed)&.round(1) || 0,
@@ -27,7 +27,7 @@ def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple
2727
total_wards_placed: stats.sum(:wards_placed) || 0,
2828
total_wards_destroyed: stats.sum(:wards_destroyed) || 0,
2929
vision_per_min: calculate_avg_vision_per_min(stats),
30-
role_comparison: calculate_role_comparison(player),
30+
role_comparison: calculate_role_comparison(@player),
3131
vision_trend: build_vision_trend(stats)
3232
}
3333

@@ -36,6 +36,10 @@ def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple
3636

3737
private
3838

39+
def set_player
40+
@player = organization_scoped(Player).find(params[:player_id])
41+
end
42+
3943
def build_vision_trend(stats)
4044
stats.map do |stat|
4145
next unless stat.match.game_start

0 commit comments

Comments
 (0)