diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index 4cb55a4..9ed5f40 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -1,249 +1,11 @@ +# frozen_string_literal: true + +# Proxy controller that inherits from the modularized Authentication controller module Api module V1 - class AuthController < BaseController - skip_before_action :authenticate_request!, only: [:register, :login, :forgot_password, :reset_password, :refresh] - - # POST /api/v1/auth/register - def register - ActiveRecord::Base.transaction do - organization = create_organization! - user = create_user!(organization) - tokens = Authentication::Services::JwtService.generate_tokens(user) - - # Log audit action with user's organization - AuditLog.create!( - organization: organization, - user: user, - action: 'register', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - UserMailer.welcome(user).deliver_later - - render_created( - { - user: JSON.parse(UserSerializer.render(user)), - organization: JSON.parse(OrganizationSerializer.render(organization)), - **tokens - }, - message: 'Registration successful' - ) - end - rescue ActiveRecord::RecordInvalid => e - render_validation_errors(e) - rescue => e - render_error(message: 'Registration failed', code: 'REGISTRATION_ERROR') - end - - # POST /api/v1/auth/login - def login - user = authenticate_user! - - if user - tokens = Authentication::Services::JwtService.generate_tokens(user) - user.update_last_login! - - AuditLog.create!( - organization: user.organization, - user: user, - action: 'login', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - render_success( - { - user: JSON.parse(UserSerializer.render(user)), - organization: JSON.parse(OrganizationSerializer.render(user.organization)), - **tokens - }, - message: 'Login successful' - ) - else - render_error( - message: 'Invalid email or password', - code: 'INVALID_CREDENTIALS', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/refresh - def refresh - refresh_token = params[:refresh_token] - - if refresh_token.blank? - return render_error( - message: 'Refresh token is required', - code: 'MISSING_REFRESH_TOKEN', - status: :bad_request - ) - end - - begin - tokens = Authentication::Services::JwtService.refresh_access_token(refresh_token) - render_success(tokens, message: 'Token refreshed successfully') - rescue Authentication::Services::JwtService::AuthenticationError => e - render_error( - message: e.message, - code: 'INVALID_REFRESH_TOKEN', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/logout - def logout - # Blacklist the current access token - token = request.headers['Authorization']&.split(' ')&.last - Authentication::Services::JwtService.blacklist_token(token) if token - - log_user_action( - action: 'logout', - entity_type: 'User', - entity_id: current_user.id - ) - - render_success({}, message: 'Logout successful') - end - - # POST /api/v1/auth/forgot-password - def forgot_password - email = params[:email]&.downcase&.strip - - if email.blank? - return render_error( - message: 'Email is required', - code: 'MISSING_EMAIL', - status: :bad_request - ) - end - - user = User.find_by(email: email) - - if user - reset_token = user.password_reset_tokens.create!( - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - UserMailer.password_reset(user, reset_token).deliver_later - - AuditLog.create!( - organization: user.organization, - user: user, - action: 'password_reset_requested', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - end - - render_success( - {}, - message: 'If the email exists, a password reset link has been sent' - ) - end - - # POST /api/v1/auth/reset-password - def reset_password - token = params[:token] - new_password = params[:password] - password_confirmation = params[:password_confirmation] - - if token.blank? || new_password.blank? - return render_error( - message: 'Token and password are required', - code: 'MISSING_PARAMETERS', - status: :bad_request - ) - end - - if new_password != password_confirmation - return render_error( - message: 'Password confirmation does not match', - code: 'PASSWORD_MISMATCH', - status: :bad_request - ) - end - - reset_token = PasswordResetToken.valid.find_by(token: token) - - if reset_token - user = reset_token.user - user.update!(password: new_password) - - reset_token.mark_as_used! - - UserMailer.password_reset_confirmation(user).deliver_later - - AuditLog.create!( - organization: user.organization, - user: user, - action: 'password_reset_completed', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - render_success({}, message: 'Password reset successful') - else - render_error( - message: 'Invalid or expired reset token', - code: 'INVALID_RESET_TOKEN', - status: :bad_request - ) - end - end - - # GET /api/v1/auth/me - def me - render_success( - { - user: JSON.parse(UserSerializer.render(current_user)), - organization: JSON.parse(OrganizationSerializer.render(current_organization)) - } - ) - end - - private - - def create_organization! - Organization.create!(organization_params) - end - - def create_user!(organization) - User.create!(user_params.merge( - organization: organization, - role: 'owner' # First user is always the owner - )) - end - - def authenticate_user! - email = params[:email]&.downcase&.strip - password = params[:password] - - return nil if email.blank? || password.blank? - - user = User.find_by(email: email) - user&.authenticate(password) ? user : nil - end - - def organization_params - params.require(:organization).permit(:name, :region, :tier) - end - - def user_params - params.require(:user).permit(:email, :password, :full_name, :timezone, :language) - end - + class AuthController < ::Authentication::Controllers::AuthController + # All functionality is inherited from Authentication::Controllers::AuthController + # This controller exists only for backwards compatibility with existing routes end end end diff --git a/app/controllers/api/v1/dashboard_controller.rb b/app/controllers/api/v1/dashboard_controller.rb index bf2d52c..a9374d4 100644 --- a/app/controllers/api/v1/dashboard_controller.rb +++ b/app/controllers/api/v1/dashboard_controller.rb @@ -1,152 +1,9 @@ -class Api::V1::DashboardController < Api::V1::BaseController - def index - dashboard_data = { - stats: calculate_stats, - recent_matches: recent_matches_data, - upcoming_events: upcoming_events_data, - active_goals: active_goals_data, - roster_status: roster_status_data - } - - render_success(dashboard_data) - end - - def stats - cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" - cached_stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) { calculate_stats } - render_success(cached_stats) - end - - def activities - recent_activities = fetch_recent_activities - - render_success({ - activities: recent_activities, - count: recent_activities.size - }) - end - - def schedule - events = organization_scoped(Schedule) - .where('start_time >= ?', Time.current) - .order(start_time: :asc) - .limit(10) - - render_success({ - events: ScheduleSerializer.render_as_hash(events), - count: events.size - }) - end - - private - - def calculate_stats - matches = organization_scoped(Match).recent(30) - players = organization_scoped(Player).active - - { - total_players: players.count, - active_players: players.where(status: 'active').count, - total_matches: matches.count, - wins: matches.victories.count, - losses: matches.defeats.count, - win_rate: calculate_win_rate(matches), - recent_form: calculate_recent_form(matches.order(game_start: :desc).limit(5)), - avg_kda: calculate_average_kda(matches), - active_goals: organization_scoped(TeamGoal).active.count, - completed_goals: organization_scoped(TeamGoal).where(status: 'completed').count, - upcoming_matches: organization_scoped(Schedule).where('start_time >= ? AND event_type = ?', Time.current, 'match').count - } - end - - def calculate_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_recent_form(matches) - matches.map { |m| m.victory? ? 'W' : 'L' }.join('') - end - - def calculate_average_kda(matches) - stats = PlayerMatchStat.where(match: matches) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end - - def recent_matches_data - matches = organization_scoped(Match) - .order(game_start: :desc) - .limit(5) - - MatchSerializer.render_as_hash(matches) - end - - def upcoming_events_data - events = organization_scoped(Schedule) - .where('start_time >= ?', Time.current) - .order(start_time: :asc) - .limit(5) - - ScheduleSerializer.render_as_hash(events) - end - - def active_goals_data - goals = organization_scoped(TeamGoal) - .active - .order(end_date: :asc) - .limit(5) - - TeamGoalSerializer.render_as_hash(goals) - end - - def roster_status_data - players = organization_scoped(Player).includes(:champion_pools) - - # Order by role to ensure consistent order in by_role hash - by_role_ordered = players.ordered_by_role.group(:role).count - - { - by_role: by_role_ordered, - by_status: players.group(:status).count, - contracts_expiring: players.contracts_expiring_soon.count - } - end - - def fetch_recent_activities - # Fetch recent audit logs and format them - activities = AuditLog - .where(organization: current_organization) - .order(created_at: :desc) - .limit(20) - - activities.map do |log| - { - id: log.id, - action: log.action, - entity_type: log.entity_type, - entity_id: log.entity_id, - user: log.user&.email, - timestamp: log.created_at, - changes: summarize_changes(log) - } - end - end - - def summarize_changes(log) - return nil unless log.new_values.present? - - # Only show important field changes - important_fields = %w[status role summoner_name title victory] - changes = log.new_values.slice(*important_fields) - - return nil if changes.empty? - changes - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class DashboardController < ::Dashboard::Controllers::DashboardController + end + end +end diff --git a/app/controllers/api/v1/matches_controller.rb b/app/controllers/api/v1/matches_controller.rb index daf0f7d..9c22181 100644 --- a/app/controllers/api/v1/matches_controller.rb +++ b/app/controllers/api/v1/matches_controller.rb @@ -1,267 +1,9 @@ -class Api::V1::MatchesController < Api::V1::BaseController - before_action :set_match, only: [:show, :update, :destroy, :stats] - - def index - matches = organization_scoped(Match).includes(:player_match_stats, :players) - - # Apply filters - matches = matches.by_type(params[:match_type]) if params[:match_type].present? - matches = matches.victories if params[:result] == 'victory' - matches = matches.defeats if params[:result] == 'defeat' - - # Date range filter - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - elsif params[:days].present? - matches = matches.recent(params[:days].to_i) - end - - # Opponent filter - matches = matches.with_opponent(params[:opponent]) if params[:opponent].present? - - # Tournament filter - if params[:tournament].present? - matches = matches.where('tournament_name ILIKE ?', "%#{params[:tournament]}%") - end - - # Sorting - sort_by = params[:sort_by] || 'game_start' - sort_order = params[:sort_order] || 'desc' - matches = matches.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(matches) - - render_success({ - matches: MatchSerializer.render_as_hash(result[:data]), - pagination: result[:pagination], - summary: calculate_matches_summary(matches) - }) - end - - def show - match_data = MatchSerializer.render_as_hash(@match) - player_stats = PlayerMatchStatSerializer.render_as_hash( - @match.player_match_stats.includes(:player) - ) - - render_success({ - match: match_data, - player_stats: player_stats, - team_composition: @match.team_composition, - mvp: @match.mvp_player ? PlayerSerializer.render_as_hash(@match.mvp_player) : nil - }) - end - - def create - match = organization_scoped(Match).new(match_params) - match.organization = current_organization - - if match.save - log_user_action( - action: 'create', - entity_type: 'Match', - entity_id: match.id, - new_values: match.attributes - ) - - render_created({ - match: MatchSerializer.render_as_hash(match) - }, message: 'Match created successfully') - else - render_error( - message: 'Failed to create match', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: match.errors.as_json - ) - end - end - - def update - old_values = @match.attributes.dup - - if @match.update(match_params) - log_user_action( - action: 'update', - entity_type: 'Match', - entity_id: @match.id, - old_values: old_values, - new_values: @match.attributes - ) - - render_updated({ - match: MatchSerializer.render_as_hash(@match) - }) - else - render_error( - message: 'Failed to update match', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @match.errors.as_json - ) - end - end - - def destroy - if @match.destroy - log_user_action( - action: 'delete', - entity_type: 'Match', - entity_id: @match.id, - old_values: @match.attributes - ) - - render_deleted(message: 'Match deleted successfully') - else - render_error( - message: 'Failed to delete match', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - def stats - # Detailed statistics for a single match - stats = @match.player_match_stats.includes(:player) - - stats_data = { - match: MatchSerializer.render_as_hash(@match), - team_stats: calculate_team_stats(stats), - player_stats: stats.map do |stat| - player_data = PlayerMatchStatSerializer.render_as_hash(stat) - player_data[:player] = PlayerSerializer.render_as_hash(stat.player) - player_data - end, - comparison: { - total_gold: stats.sum(:gold_earned), - total_damage: stats.sum(:total_damage_dealt), - total_vision_score: stats.sum(:vision_score), - avg_kda: calculate_avg_kda(stats) - } - } - - render_success(stats_data) - end - - def import - player_id = params[:player_id] - count = params[:count]&.to_i || 20 - - unless player_id.present? - return render_error( - message: 'player_id is required', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - end - - player = organization_scoped(Player).find(player_id) - - unless player.riot_puuid.present? - return render_error( - message: 'Player does not have a Riot PUUID. Please sync player from Riot first.', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - end - - begin - riot_service = RiotApiService.new - region = player.region || 'BR' - - match_ids = riot_service.get_match_history( - puuid: player.riot_puuid, - region: region, - count: count - ) - - imported_count = 0 - match_ids.each do |match_id| - # Check if match already exists - next if Match.exists?(riot_match_id: match_id) - - SyncMatchJob.perform_later(match_id, current_organization.id, region) - imported_count += 1 - end - - render_success({ - message: "Queued #{imported_count} matches for import", - total_matches_found: match_ids.count, - already_imported: match_ids.count - imported_count, - player: PlayerSerializer.render_as_hash(player) - }) - - rescue RiotApiService::RiotApiError => e - render_error( - message: "Failed to fetch matches from Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :bad_gateway - ) - rescue StandardError => e - render_error( - message: "Failed to import matches: #{e.message}", - code: 'IMPORT_ERROR', - status: :internal_server_error - ) - end - end - - private - - def set_match - @match = organization_scoped(Match).find(params[:id]) - end - - def match_params - params.require(:match).permit( - :match_type, :game_start, :game_end, :game_duration, - :riot_match_id, :patch_version, :tournament_name, :stage, - :opponent_name, :opponent_tag, :victory, - :our_side, :our_score, :opponent_score, - :first_blood, :first_tower, :first_baron, :first_dragon, - :total_kills, :total_deaths, :total_assists, :total_gold, - :vod_url, :replay_file_url, :notes - ) - end - - def calculate_matches_summary(matches) - { - total: matches.count, - victories: matches.victories.count, - defeats: matches.defeats.count, - win_rate: calculate_win_rate(matches), - by_type: matches.group(:match_type).count, - avg_duration: matches.average(:game_duration)&.round(0) - } - end - - def calculate_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_team_stats(stats) - { - total_kills: stats.sum(:kills), - total_deaths: stats.sum(:deaths), - total_assists: stats.sum(:assists), - total_gold: stats.sum(:gold_earned), - total_damage: stats.sum(:total_damage_dealt), - total_cs: stats.sum(:minions_killed), - total_vision_score: stats.sum(:vision_score) - } - end - - def calculate_avg_kda(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class MatchesController < ::Matches::Controllers::MatchesController + end + end +end diff --git a/app/controllers/api/v1/players_controller.rb b/app/controllers/api/v1/players_controller.rb index 4c8205b..b76e9de 100644 --- a/app/controllers/api/v1/players_controller.rb +++ b/app/controllers/api/v1/players_controller.rb @@ -1,752 +1,12 @@ -class Api::V1::PlayersController < Api::V1::BaseController - before_action :set_player, only: [:show, :update, :destroy, :stats, :matches, :sync_from_riot] +# frozen_string_literal: true - def index - players = organization_scoped(Player).includes(:champion_pools) - - # Apply filters - players = players.by_role(params[:role]) if params[:role].present? - players = players.by_status(params[:status]) if params[:status].present? - - # Apply search - if params[:search].present? - search_term = "%#{params[:search]}%" - players = players.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) - end - - # Pagination - order by role (top, jungle, mid, adc, support) then by name - result = paginate(players.ordered_by_role.order(:summoner_name)) - - render_success({ - players: PlayerSerializer.render_as_hash(result[:data]), - pagination: result[:pagination] - }) - end - - def show - render_success({ - player: PlayerSerializer.render_as_hash(@player) - }) - end - - def create - player = organization_scoped(Player).new(player_params) - player.organization = current_organization - - if player.save - log_user_action( - action: 'create', - entity_type: 'Player', - entity_id: player.id, - new_values: player.attributes - ) - - render_created({ - player: PlayerSerializer.render_as_hash(player) - }, message: 'Player created successfully') - else - render_error( - message: 'Failed to create player', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: player.errors.as_json - ) - end - end - - def update - old_values = @player.attributes.dup - - if @player.update(player_params) - log_user_action( - action: 'update', - entity_type: 'Player', - entity_id: @player.id, - old_values: old_values, - new_values: @player.attributes - ) - - render_updated({ - player: PlayerSerializer.render_as_hash(@player) - }) - else - render_error( - message: 'Failed to update player', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @player.errors.as_json - ) - end - end - - def destroy - if @player.destroy - log_user_action( - action: 'delete', - entity_type: 'Player', - entity_id: @player.id, - old_values: @player.attributes - ) - - render_deleted(message: 'Player deleted successfully') - else - render_error( - message: 'Failed to delete player', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - def stats - # Get player statistics - matches = @player.matches.order(game_start: :desc) - recent_matches = matches.limit(20) - player_stats = PlayerMatchStat.where(player: @player, match: matches) - - stats_data = { - player: PlayerSerializer.render_as_hash(@player), - overall: { - total_matches: matches.count, - wins: matches.victories.count, - losses: matches.defeats.count, - win_rate: calculate_player_win_rate(matches), - avg_kda: calculate_player_avg_kda(player_stats), - avg_cs: player_stats.average(:minions_killed)&.round(1) || 0, - avg_vision_score: player_stats.average(:vision_score)&.round(1) || 0, - avg_damage: player_stats.average(:total_damage_dealt)&.round(0) || 0 - }, - recent_form: { - last_5_matches: calculate_recent_form(recent_matches.limit(5)), - last_10_matches: calculate_recent_form(recent_matches.limit(10)) - }, - champion_pool: ChampionPoolSerializer.render_as_hash( - @player.champion_pools.order(games_played: :desc).limit(5) - ), - performance_by_role: calculate_performance_by_role(player_stats) - } - - render_success(stats_data) - end - - def matches - matches = @player.matches - .includes(:player_match_stats) - .order(game_start: :desc) - - # Filter by date range if provided - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - end - - result = paginate(matches) - - # Include player stats for each match - matches_with_stats = result[:data].map do |match| - player_stat = match.player_match_stats.find_by(player: @player) - { - match: MatchSerializer.render_as_hash(match), - player_stats: player_stat ? PlayerMatchStatSerializer.render_as_hash(player_stat) : nil - } - end - - render_success({ - matches: matches_with_stats, - pagination: result[:pagination] - }) - end - - def import - summoner_name = params[:summoner_name]&.strip - role = params[:role] - region = params[:region] || 'br1' - - # Validate required params - unless summoner_name.present? && role.present? - return render_error( - message: 'Summoner name and role are required', - code: 'MISSING_PARAMETERS', - status: :unprocessable_entity, - details: { - hint: 'Format: "GameName#TAG" or "GameName-TAG" (e.g., "Faker#KR1" or "Faker-KR1")' - } - ) - end - - # Validate role - unless %w[top jungle mid adc support].include?(role) - return render_error( - message: 'Invalid role', - code: 'INVALID_ROLE', - status: :unprocessable_entity - ) - end - - # Check if player already exists - existing_player = organization_scoped(Player).find_by(summoner_name: summoner_name) - if existing_player - return render_error( - message: 'Player already exists in your organization', - code: 'PLAYER_EXISTS', - status: :unprocessable_entity - ) - end - - # Get Riot API key - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - begin - # Try to fetch summoner data from Riot API with multiple tag variations - summoner_data = nil - game_name, tag_line = parse_riot_id(summoner_name, region) - - # Try different tag variations - tag_variations = [ - tag_line, # Original parsed tag (e.g., 'FLP' from 'veigh baby uhh-flp') - tag_line&.downcase, # lowercase (e.g., 'flp') - tag_line&.upcase, # UPPERCASE (e.g., 'FLP') - tag_line&.capitalize, # Capitalized (e.g., 'Flp') - region.upcase, # BR1 - region[0..1].upcase, # BR - 'BR1', 'BRSL', 'BR', 'br1', 'LAS', 'LAN' # Common tags - ].compact.uniq - - last_error = nil - account_data = nil - tag_variations.each do |tag| - begin - Rails.logger.info "Trying Riot ID: #{game_name}##{tag}" - account_data = fetch_summoner_by_riot_id(game_name, tag, region, riot_api_key) - - puuid = account_data['puuid'] - summoner_data = fetch_summoner_by_puuid(puuid, region, riot_api_key) - - summoner_name = "#{account_data['gameName']}##{account_data['tagLine']}" - - Rails.logger.info "✅ Found player: #{summoner_name}" - break - rescue => e - last_error = e - Rails.logger.debug "Tag '#{tag}' failed: #{e.message}" - next - end - end - - unless summoner_data - raise "Player not found. Tried: #{tag_variations.map { |t| "#{game_name}##{t}" }.join(', ')}. Original error: #{last_error&.message}" - end - - ranked_data = fetch_ranked_stats(summoner_data['puuid'], region, riot_api_key) - - player_data = { - summoner_name: summoner_name, - role: role, - region: region, - status: 'active', - riot_puuid: summoner_data['puuid'], - riot_summoner_id: summoner_data['id'], - summoner_level: summoner_data['summonerLevel'], - profile_icon_id: summoner_data['profileIconId'], - sync_status: 'success', - last_sync_at: Time.current - } - - solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } - if solo_queue - player_data.merge!({ - solo_queue_tier: solo_queue['tier'], - solo_queue_rank: solo_queue['rank'], - solo_queue_lp: solo_queue['leaguePoints'], - solo_queue_wins: solo_queue['wins'], - solo_queue_losses: solo_queue['losses'] - }) - end - - flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } - if flex_queue - player_data.merge!({ - flex_queue_tier: flex_queue['tier'], - flex_queue_rank: flex_queue['rank'], - flex_queue_lp: flex_queue['leaguePoints'] - }) - end - - player = organization_scoped(Player).create!(player_data) - - log_user_action( - action: 'import_riot', - entity_type: 'Player', - entity_id: player.id, - new_values: player_data - ) - - render_created({ - player: PlayerSerializer.render_as_hash(player), - message: "Player #{summoner_name} imported successfully from Riot API" - }) - - rescue ActiveRecord::RecordInvalid => e - render_error( - message: "Failed to create player: #{e.message}", - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - rescue StandardError => e - Rails.logger.error "Riot API import error: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - - render_error( - message: "Failed to import from Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :service_unavailable - ) - end - end - - def sync_from_riot - # Check if player has riot_puuid or summoner_name - unless @player.riot_puuid.present? || @player.summoner_name.present? - return render_error( - message: 'Player must have either Riot PUUID or summoner name to sync', - code: 'MISSING_RIOT_INFO', - status: :unprocessable_entity - ) - end - - # Get Riot API key from environment - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - begin - # If we have PUUID, get summoner info by PUUID - # If not, get summoner by name first to get PUUID - region = params[:region] || 'br1' - - if @player.riot_puuid.present? - summoner_data = fetch_summoner_by_puuid(@player.riot_puuid, region, riot_api_key) - else - summoner_data = fetch_summoner_by_name(@player.summoner_name, region, riot_api_key) - end - - # Get ranked stats using PUUID - ranked_data = fetch_ranked_stats(summoner_data['puuid'], region, riot_api_key) - - # Update player with fresh data - update_data = { - riot_puuid: summoner_data['puuid'], - riot_summoner_id: summoner_data['id'] - } - - # Update ranked stats if available - solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } - if solo_queue - update_data.merge!({ - solo_queue_tier: solo_queue['tier'], - solo_queue_rank: solo_queue['rank'], - solo_queue_lp: solo_queue['leaguePoints'], - solo_queue_wins: solo_queue['wins'], - solo_queue_losses: solo_queue['losses'] - }) - end - - flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } - if flex_queue - update_data.merge!({ - flex_queue_tier: flex_queue['tier'], - flex_queue_rank: flex_queue['rank'], - flex_queue_lp: flex_queue['leaguePoints'] - }) - end - - update_data[:sync_status] = 'success' - update_data[:last_sync_at] = Time.current - - @player.update!(update_data) - - log_user_action( - action: 'sync_riot', - entity_type: 'Player', - entity_id: @player.id, - new_values: update_data - ) - - render_success({ - player: PlayerSerializer.render_as_hash(@player), - message: 'Player synced successfully from Riot API' - }) - - rescue StandardError => e - Rails.logger.error "Riot API sync error: #{e.message}" - - # Update sync status to error - @player.update(sync_status: 'error', last_sync_at: Time.current) - - render_error( - message: "Failed to sync with Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :service_unavailable - ) - end - end - - def search_riot_id - summoner_name = params[:summoner_name]&.strip - region = params[:region] || 'br1' - - unless summoner_name.present? - return render_error( - message: 'Summoner name is required', - code: 'MISSING_PARAMETERS', - status: :unprocessable_entity - ) +# Proxy controller that inherits from the modularized Players controller +# This allows seamless migration to modular architecture without breaking existing routes +module Api + module V1 + class PlayersController < ::Players::Controllers::PlayersController + # All functionality is inherited from Players::Controllers::PlayersController + # This controller exists only for backwards compatibility with existing routes end - - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - begin - # Parse the summoner name - game_name, tag_line = parse_riot_id(summoner_name, region) - - # If tagline was provided, try exact match first - if summoner_name.include?('#') || summoner_name.include?('-') - begin - summoner_data = fetch_summoner_by_riot_id(game_name, tag_line, region, riot_api_key) - return render_success({ - found: true, - game_name: summoner_data['gameName'], - tag_line: summoner_data['tagLine'], - puuid: summoner_data['puuid'], - riot_id: "#{summoner_data['gameName']}##{summoner_data['tagLine']}" - }) - rescue => e - Rails.logger.info "Exact match failed: #{e.message}" - end - end - - # Try common tagline variations - common_tags = [ - tag_line, # Original parsed tag - tag_line&.downcase, # lowercase - tag_line&.upcase, # UPPERCASE - tag_line&.capitalize, # Capitalized - region.upcase, # BR1 - region[0..1].upcase, # BR - 'BR1', 'BRSL', 'BR', 'br1', 'LAS', 'LAN' # Common tags - ].compact.uniq - - results = [] - common_tags.each do |tag| - begin - summoner_data = fetch_summoner_by_riot_id(game_name, tag, region, riot_api_key) - results << { - game_name: summoner_data['gameName'], - tag_line: summoner_data['tagLine'], - puuid: summoner_data['puuid'], - riot_id: "#{summoner_data['gameName']}##{summoner_data['tagLine']}" - } - break - rescue => e - Rails.logger.debug "Tag '#{tag}' not found: #{e.message}" - next - end - end - - if results.any? - render_success({ - found: true, - **results.first, - message: "Player found! Use this Riot ID: #{results.first[:riot_id]}" - }) - else - render_error( - message: "Player not found. Tried game name '#{game_name}' with tags: #{common_tags.join(', ')}", - code: 'PLAYER_NOT_FOUND', - status: :not_found, - details: { - game_name: game_name, - tried_tags: common_tags, - hint: 'Please verify the exact Riot ID in the League client (Settings > Account > Riot ID)' - } - ) - end - - rescue StandardError => e - Rails.logger.error "Riot ID search error: #{e.message}" - render_error( - message: "Failed to search Riot ID: #{e.message}", - code: 'SEARCH_ERROR', - status: :service_unavailable - ) - end - end - - def bulk_sync - status = params[:status] || 'active' - - # Get players to sync - players = organization_scoped(Player).where(status: status) - - if players.empty? - return render_error( - message: "No #{status} players found to sync", - code: 'NO_PLAYERS_FOUND', - status: :not_found - ) - end - - # Check if Riot API is configured - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - # Queue all players for sync (mark as syncing) - players.update_all(sync_status: 'syncing') - - # Perform sync in background - players.each do |player| - SyncPlayerFromRiotJob.perform_later(player.id) - end - - render_success({ - message: "#{players.count} players queued for sync", - players_count: players.count - }) - end - - private - - def set_player - @player = organization_scoped(Player).find(params[:id]) - end - - def parse_riot_id(summoner_name, region) - if summoner_name.include?('#') - game_name, tag_line = summoner_name.split('#', 2) - elsif summoner_name.include?('-') - parts = summoner_name.rpartition('-') - game_name = parts[0] - tag_line = parts[2] - else - game_name = summoner_name - tag_line = nil - end - - tag_line ||= region.upcase - tag_line = tag_line.strip.upcase if tag_line - - [game_name, tag_line] - end - def riot_url_encode(string) - URI.encode_www_form_component(string).gsub('+', '%20') - end - - def fetch_summoner_by_riot_id(game_name, tag_line, region, api_key) - require 'net/http' - require 'json' - - account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{riot_url_encode(game_name)}/#{riot_url_encode(tag_line)}" - account_uri = URI(account_url) - account_request = Net::HTTP::Get.new(account_uri) - account_request['X-Riot-Token'] = api_key - - account_response = Net::HTTP.start(account_uri.hostname, account_uri.port, use_ssl: true) do |http| - http.request(account_request) - end - - unless account_response.is_a?(Net::HTTPSuccess) - raise "Not found: #{game_name}##{tag_line}" - end - - JSON.parse(account_response.body) - end - - def player_params - # :role refers to in-game position (top/jungle/mid/adc/support), not user role - # nosemgrep - params.require(:player).permit( - :summoner_name, :real_name, :role, :region, :status, :jersey_number, - :birth_date, :country, :nationality, - :contract_start_date, :contract_end_date, - :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, - :solo_queue_wins, :solo_queue_losses, - :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, - :peak_tier, :peak_rank, :peak_season, - :riot_puuid, :riot_summoner_id, - :twitter_handle, :twitch_channel, :instagram_handle, - :notes - ) - end - - def calculate_player_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_player_avg_kda(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end - - def calculate_recent_form(matches) - matches.map { |m| m.victory? ? 'W' : 'L' } - end - - def calculate_performance_by_role(stats) - stats.group(:role).select( - 'role', - 'COUNT(*) as games', - 'AVG(kills) as avg_kills', - 'AVG(deaths) as avg_deaths', - 'AVG(assists) as avg_assists', - 'AVG(performance_score) as avg_performance' - ).map do |stat| - { - role: stat.role, - games: stat.games, - avg_kda: { - kills: stat.avg_kills&.round(1) || 0, - deaths: stat.avg_deaths&.round(1) || 0, - assists: stat.avg_assists&.round(1) || 0 - }, - avg_performance: stat.avg_performance&.round(1) || 0 - } - end - end - - def fetch_summoner_by_name(summoner_name, region, api_key) - require 'net/http' - require 'json' - - # Parse the Riot ID - game_name, tag_line = parse_riot_id(summoner_name, region) - - # Try different tag variations (same as create_from_riot) - tag_variations = [ - tag_line, # Original parsed tag - tag_line&.downcase, # lowercase - tag_line&.upcase, # UPPERCASE - tag_line&.capitalize, # Capitalized - region.upcase, # BR1 - region[0..1].upcase, # BR - 'BR1', 'BRSL', 'BR', 'br1', 'LAS', 'LAN' # Common tags - ].compact.uniq - - last_error = nil - account_data = nil - - tag_variations.each do |tag| - begin - Rails.logger.info "Trying Riot ID: #{game_name}##{tag}" - - # First, get PUUID from Riot ID - account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{riot_url_encode(game_name)}/#{riot_url_encode(tag)}" - account_uri = URI(account_url) - account_request = Net::HTTP::Get.new(account_uri) - account_request['X-Riot-Token'] = api_key - - account_response = Net::HTTP.start(account_uri.hostname, account_uri.port, use_ssl: true) do |http| - http.request(account_request) - end - - if account_response.is_a?(Net::HTTPSuccess) - account_data = JSON.parse(account_response.body) - Rails.logger.info "✅ Found player: #{game_name}##{tag}" - break - else - Rails.logger.debug "Tag '#{tag}' failed: #{account_response.code}" - next - end - rescue => e - last_error = e - Rails.logger.debug "Tag '#{tag}' failed: #{e.message}" - next - end - end - - unless account_data - # Log the attempted search for debugging - Rails.logger.error "Failed to find Riot ID after trying all variations" - Rails.logger.error "Tried tags: #{tag_variations.join(', ')}" - - error_msg = "Player not found with Riot ID '#{game_name}'. Tried tags: #{tag_variations.map { |t| "#{game_name}##{t}" }.join(', ')}. Original error: #{last_error&.message}" - raise error_msg - end - - puuid = account_data['puuid'] - - # Now get summoner by PUUID - fetch_summoner_by_puuid(puuid, region, api_key) - end - - def fetch_summoner_by_puuid(puuid, region, api_key) - require 'net/http' - require 'json' - - url = "https://#{region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" - uri = URI(url) - request = Net::HTTP::Get.new(uri) - request['X-Riot-Token'] = api_key - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" - end - - JSON.parse(response.body) - end - - def fetch_ranked_stats(puuid, region, api_key) - require 'net/http' - require 'json' - - # Riot API v4 now uses PUUID instead of summoner ID - url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" - uri = URI(url) - request = Net::HTTP::Get.new(uri) - request['X-Riot-Token'] = api_key - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" - end - - JSON.parse(response.body) end end diff --git a/app/controllers/api/v1/riot_data_controller.rb b/app/controllers/api/v1/riot_data_controller.rb index 7f30517..2e826a3 100644 --- a/app/controllers/api/v1/riot_data_controller.rb +++ b/app/controllers/api/v1/riot_data_controller.rb @@ -1,144 +1,9 @@ -module Api - module V1 - class RiotDataController < BaseController - skip_before_action :authenticate_request!, only: [:champions, :champion_details, :items, :version] - - # GET /api/v1/riot-data/champions - def champions - service = DataDragonService.new - champions = service.champion_id_map - - render_success({ - champions: champions, - count: champions.count - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch champion data', :service_unavailable, details: e.message) - end - - # GET /api/v1/riot-data/champions/:champion_key - def champion_details - service = DataDragonService.new - champion = service.champion_by_key(params[:champion_key]) - - if champion.present? - render_success({ - champion: champion - }) - else - render_error('Champion not found', :not_found) - end - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch champion details', :service_unavailable, details: e.message) - end - - # GET /api/v1/riot-data/all-champions - def all_champions - service = DataDragonService.new - champions = service.all_champions - - render_success({ - champions: champions, - count: champions.count - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch champions', :service_unavailable, details: e.message) - end - - # GET /api/v1/riot-data/items - def items - service = DataDragonService.new - items = service.items - - render_success({ - items: items, - count: items.count - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch items', :service_unavailable, details: e.message) - end - - # GET /api/v1/riot-data/summoner-spells - def summoner_spells - service = DataDragonService.new - spells = service.summoner_spells - - render_success({ - summoner_spells: spells, - count: spells.count - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch summoner spells', :service_unavailable, details: e.message) - end - - # GET /api/v1/riot-data/version - def version - service = DataDragonService.new - version = service.latest_version - - render_success({ - version: version - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to fetch version', :service_unavailable, details: e.message) - end - - # POST /api/v1/riot-data/clear-cache - def clear_cache - authorize :riot_data, :manage? - - service = DataDragonService.new - service.clear_cache! - - log_user_action( - action: 'clear_cache', - entity_type: 'RiotData', - entity_id: nil, - details: { message: 'Data Dragon cache cleared' } - ) - - render_success({ - message: 'Cache cleared successfully' - }) - end - - # POST /api/v1/riot-data/update-cache - def update_cache - authorize :riot_data, :manage? - - service = DataDragonService.new - service.clear_cache! - - # Preload all data - version = service.latest_version - champions = service.champion_id_map - items = service.items - spells = service.summoner_spells - - log_user_action( - action: 'update_cache', - entity_type: 'RiotData', - entity_id: nil, - details: { - version: version, - champions_count: champions.count, - items_count: items.count, - spells_count: spells.count - } - ) - - render_success({ - message: 'Cache updated successfully', - version: version, - data: { - champions: champions.count, - items: items.count, - summoner_spells: spells.count - } - }) - rescue DataDragonService::DataDragonError => e - render_error('Failed to update cache', :service_unavailable, details: e.message) - end - end - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class RiotDataController < ::RiotIntegration::Controllers::RiotDataController + end + end +end diff --git a/app/controllers/api/v1/riot_integration_controller.rb b/app/controllers/api/v1/riot_integration_controller.rb index 7695239..6362b93 100644 --- a/app/controllers/api/v1/riot_integration_controller.rb +++ b/app/controllers/api/v1/riot_integration_controller.rb @@ -1,45 +1,9 @@ -class Api::V1::RiotIntegrationController < Api::V1::BaseController - def sync_status - players = organization_scoped(Player) - - # Calculate statistics - total_players = players.count - synced_players = players.where(sync_status: 'success').count - pending_sync = players.where(sync_status: ['pending', nil]).or(players.where(sync_status: nil)).count - failed_sync = players.where(sync_status: 'error').count - - # Players synced in last 24 hours - recently_synced = players.where('last_sync_at > ?', 24.hours.ago).count - - # Players that need sync (never synced or synced more than 1 hour ago) - needs_sync = players.where(last_sync_at: nil) - .or(players.where('last_sync_at < ?', 1.hour.ago)) - .count - - # Get recent syncs (last 10) - recent_syncs = players - .where.not(last_sync_at: nil) - .order(last_sync_at: :desc) - .limit(10) - .map do |player| - { - id: player.id, - summoner_name: player.summoner_name, - last_sync_at: player.last_sync_at, - sync_status: player.sync_status || 'pending' - } - end - - render_success({ - stats: { - total_players: total_players, - synced_players: synced_players, - pending_sync: pending_sync, - failed_sync: failed_sync, - recently_synced: recently_synced, - needs_sync: needs_sync - }, - recent_syncs: recent_syncs - }) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class RiotIntegrationController < ::RiotIntegration::Controllers::RiotIntegrationController + end + end +end diff --git a/app/controllers/api/v1/schedules_controller.rb b/app/controllers/api/v1/schedules_controller.rb index 5eed2a1..8c4d723 100644 --- a/app/controllers/api/v1/schedules_controller.rb +++ b/app/controllers/api/v1/schedules_controller.rb @@ -1,135 +1,9 @@ -class Api::V1::SchedulesController < Api::V1::BaseController - before_action :set_schedule, only: [:show, :update, :destroy] - - def index - schedules = organization_scoped(Schedule).includes(:match) - - # Apply filters - schedules = schedules.where(event_type: params[:event_type]) if params[:event_type].present? - schedules = schedules.where(status: params[:status]) if params[:status].present? - - # Date range filter - if params[:start_date].present? && params[:end_date].present? - schedules = schedules.where(start_time: params[:start_date]..params[:end_date]) - elsif params[:upcoming] == 'true' - schedules = schedules.where('start_time >= ?', Time.current) - elsif params[:past] == 'true' - schedules = schedules.where('end_time < ?', Time.current) - end - - # Today's events - if params[:today] == 'true' - schedules = schedules.where(start_time: Time.current.beginning_of_day..Time.current.end_of_day) - end - - # This week's events - if params[:this_week] == 'true' - schedules = schedules.where(start_time: Time.current.beginning_of_week..Time.current.end_of_week) - end - - # Sorting - sort_order = params[:sort_order] || 'asc' - schedules = schedules.order("start_time #{sort_order}") - - # Pagination - result = paginate(schedules) - - render_success({ - schedules: ScheduleSerializer.render_as_hash(result[:data]), - pagination: result[:pagination] - }) - end - - def show - render_success({ - schedule: ScheduleSerializer.render_as_hash(@schedule) - }) - end - - def create - schedule = organization_scoped(Schedule).new(schedule_params) - schedule.organization = current_organization - - if schedule.save - log_user_action( - action: 'create', - entity_type: 'Schedule', - entity_id: schedule.id, - new_values: schedule.attributes - ) - - render_created({ - schedule: ScheduleSerializer.render_as_hash(schedule) - }, message: 'Event scheduled successfully') - else - render_error( - message: 'Failed to create schedule', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: schedule.errors.as_json - ) - end - end - - def update - old_values = @schedule.attributes.dup - - if @schedule.update(schedule_params) - log_user_action( - action: 'update', - entity_type: 'Schedule', - entity_id: @schedule.id, - old_values: old_values, - new_values: @schedule.attributes - ) - - render_updated({ - schedule: ScheduleSerializer.render_as_hash(@schedule) - }) - else - render_error( - message: 'Failed to update schedule', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @schedule.errors.as_json - ) - end - end - - def destroy - if @schedule.destroy - log_user_action( - action: 'delete', - entity_type: 'Schedule', - entity_id: @schedule.id, - old_values: @schedule.attributes - ) - - render_deleted(message: 'Event deleted successfully') - else - render_error( - message: 'Failed to delete schedule', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_schedule - @schedule = organization_scoped(Schedule).find(params[:id]) - end - - def schedule_params - params.require(:schedule).permit( - :event_type, :title, :description, - :start_time, :end_time, :location, - :opponent_name, :status, :match_id, - :meeting_url, :all_day, :timezone, - :color, :is_recurring, :recurrence_rule, - :recurrence_end_date, :reminder_minutes, - required_players: [], optional_players: [], tags: [] - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class SchedulesController < ::Schedules::Controllers::SchedulesController + end + end +end diff --git a/app/controllers/api/v1/team_goals_controller.rb b/app/controllers/api/v1/team_goals_controller.rb index 2634d07..72547aa 100644 --- a/app/controllers/api/v1/team_goals_controller.rb +++ b/app/controllers/api/v1/team_goals_controller.rb @@ -1,139 +1,9 @@ -class Api::V1::TeamGoalsController < Api::V1::BaseController - before_action :set_team_goal, only: [:show, :update, :destroy] - - def index - goals = organization_scoped(TeamGoal).includes(:player, :assigned_to, :created_by) - - # Apply filters - goals = goals.by_status(params[:status]) if params[:status].present? - goals = goals.by_category(params[:category]) if params[:category].present? - goals = goals.for_player(params[:player_id]) if params[:player_id].present? - - # Special filters - goals = goals.team_goals if params[:type] == 'team' - goals = goals.player_goals if params[:type] == 'player' - goals = goals.active if params[:active] == 'true' - goals = goals.overdue if params[:overdue] == 'true' - goals = goals.expiring_soon(params[:expiring_days]&.to_i || 7) if params[:expiring_soon] == 'true' - - # Assigned to filter - goals = goals.where(assigned_to_id: params[:assigned_to_id]) if params[:assigned_to_id].present? - - # Sorting - sort_by = params[:sort_by] || 'created_at' - sort_order = params[:sort_order] || 'desc' - goals = goals.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(goals) - - render_success({ - goals: TeamGoalSerializer.render_as_hash(result[:data]), - pagination: result[:pagination], - summary: calculate_goals_summary(goals) - }) - end - - def show - render_success({ - goal: TeamGoalSerializer.render_as_hash(@goal) - }) - end - - def create - goal = organization_scoped(TeamGoal).new(team_goal_params) - goal.organization = current_organization - goal.created_by = current_user - - if goal.save - log_user_action( - action: 'create', - entity_type: 'TeamGoal', - entity_id: goal.id, - new_values: goal.attributes - ) - - render_created({ - goal: TeamGoalSerializer.render_as_hash(goal) - }, message: 'Goal created successfully') - else - render_error( - message: 'Failed to create goal', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: goal.errors.as_json - ) - end - end - - def update - old_values = @goal.attributes.dup - - if @goal.update(team_goal_params) - log_user_action( - action: 'update', - entity_type: 'TeamGoal', - entity_id: @goal.id, - old_values: old_values, - new_values: @goal.attributes - ) - - render_updated({ - goal: TeamGoalSerializer.render_as_hash(@goal) - }) - else - render_error( - message: 'Failed to update goal', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @goal.errors.as_json - ) - end - end - - def destroy - if @goal.destroy - log_user_action( - action: 'delete', - entity_type: 'TeamGoal', - entity_id: @goal.id, - old_values: @goal.attributes - ) - - render_deleted(message: 'Goal deleted successfully') - else - render_error( - message: 'Failed to delete goal', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_team_goal - @goal = organization_scoped(TeamGoal).find(params[:id]) - end - - def team_goal_params - params.require(:team_goal).permit( - :title, :description, :category, :metric_type, - :target_value, :current_value, :start_date, :end_date, - :status, :progress, :notes, - :player_id, :assigned_to_id - ) - end - - def calculate_goals_summary(goals) - { - total: goals.count, - by_status: goals.group(:status).count, - by_category: goals.group(:category).count, - active_count: goals.active.count, - completed_count: goals.where(status: 'completed').count, - overdue_count: goals.overdue.count, - avg_progress: goals.active.average(:progress)&.round(1) || 0 - } - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class TeamGoalsController < ::TeamGoals::Controllers::TeamGoalsController + end + end +end diff --git a/app/controllers/api/v1/vod_reviews_controller.rb b/app/controllers/api/v1/vod_reviews_controller.rb index a0b7b9f..f5c6c22 100644 --- a/app/controllers/api/v1/vod_reviews_controller.rb +++ b/app/controllers/api/v1/vod_reviews_controller.rb @@ -1,137 +1,9 @@ -class Api::V1::VodReviewsController < Api::V1::BaseController - before_action :set_vod_review, only: [:show, :update, :destroy] - - def index - authorize VodReview - vod_reviews = organization_scoped(VodReview).includes(:match, :reviewer) - - # Apply filters - vod_reviews = vod_reviews.where(status: params[:status]) if params[:status].present? - - # Match filter - vod_reviews = vod_reviews.where(match_id: params[:match_id]) if params[:match_id].present? - - # Reviewed by filter - vod_reviews = vod_reviews.where(reviewer_id: params[:reviewer_id]) if params[:reviewer_id].present? - - # Search by title - if params[:search].present? - search_term = "%#{params[:search]}%" - vod_reviews = vod_reviews.where('title ILIKE ?', search_term) - end - - # Sorting - sort_by = params[:sort_by] || 'created_at' - sort_order = params[:sort_order] || 'desc' - vod_reviews = vod_reviews.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(vod_reviews) - - render_success({ - vod_reviews: VodReviewSerializer.render_as_hash(result[:data], include_timestamps_count: true), - pagination: result[:pagination] - }) - end - - def show - authorize @vod_review - vod_review_data = VodReviewSerializer.render_as_hash(@vod_review) - timestamps = VodTimestampSerializer.render_as_hash( - @vod_review.vod_timestamps.includes(:target_player, :created_by).order(:timestamp_seconds) - ) - - render_success({ - vod_review: vod_review_data, - timestamps: timestamps - }) - end - - def create - authorize VodReview - vod_review = organization_scoped(VodReview).new(vod_review_params) - vod_review.organization = current_organization - vod_review.reviewer = current_user - - if vod_review.save - log_user_action( - action: 'create', - entity_type: 'VodReview', - entity_id: vod_review.id, - new_values: vod_review.attributes - ) - - render_created({ - vod_review: VodReviewSerializer.render_as_hash(vod_review) - }, message: 'VOD review created successfully') - else - render_error( - message: 'Failed to create VOD review', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: vod_review.errors.as_json - ) - end - end - - def update - authorize @vod_review - old_values = @vod_review.attributes.dup - - if @vod_review.update(vod_review_params) - log_user_action( - action: 'update', - entity_type: 'VodReview', - entity_id: @vod_review.id, - old_values: old_values, - new_values: @vod_review.attributes - ) - - render_updated({ - vod_review: VodReviewSerializer.render_as_hash(@vod_review) - }) - else - render_error( - message: 'Failed to update VOD review', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @vod_review.errors.as_json - ) - end - end - - def destroy - authorize @vod_review - if @vod_review.destroy - log_user_action( - action: 'delete', - entity_type: 'VodReview', - entity_id: @vod_review.id, - old_values: @vod_review.attributes - ) - - render_deleted(message: 'VOD review deleted successfully') - else - render_error( - message: 'Failed to delete VOD review', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_vod_review - @vod_review = organization_scoped(VodReview).find(params[:id]) - end - - def vod_review_params - params.require(:vod_review).permit( - :title, :description, :review_type, :review_date, - :video_url, :thumbnail_url, :duration, - :status, :is_public, :match_id, - tags: [], shared_with_players: [] - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class VodReviewsController < ::VodReviews::Controllers::VodReviewsController + end + end +end diff --git a/app/controllers/api/v1/vod_timestamps_controller.rb b/app/controllers/api/v1/vod_timestamps_controller.rb index 5236fe6..1a0851c 100644 --- a/app/controllers/api/v1/vod_timestamps_controller.rb +++ b/app/controllers/api/v1/vod_timestamps_controller.rb @@ -1,111 +1,9 @@ -class Api::V1::VodTimestampsController < Api::V1::BaseController - before_action :set_vod_review, only: [:index, :create] - before_action :set_vod_timestamp, only: [:update, :destroy] - - def index - authorize @vod_review, :show? - timestamps = @vod_review.vod_timestamps - .includes(:target_player, :created_by) - .order(:timestamp_seconds) - - # Apply filters - timestamps = timestamps.where(category: params[:category]) if params[:category].present? - timestamps = timestamps.where(importance: params[:importance]) if params[:importance].present? - timestamps = timestamps.where(target_player_id: params[:player_id]) if params[:player_id].present? - - render_success({ - timestamps: VodTimestampSerializer.render_as_hash(timestamps) - }) - end - - def create - authorize @vod_review, :update? - timestamp = @vod_review.vod_timestamps.new(vod_timestamp_params) - timestamp.created_by = current_user - - if timestamp.save - log_user_action( - action: 'create', - entity_type: 'VodTimestamp', - entity_id: timestamp.id, - new_values: timestamp.attributes - ) - - render_created({ - timestamp: VodTimestampSerializer.render_as_hash(timestamp) - }, message: 'Timestamp added successfully') - else - render_error( - message: 'Failed to create timestamp', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: timestamp.errors.as_json - ) - end - end - - def update - authorize @timestamp.vod_review, :update? - old_values = @timestamp.attributes.dup - - if @timestamp.update(vod_timestamp_params) - log_user_action( - action: 'update', - entity_type: 'VodTimestamp', - entity_id: @timestamp.id, - old_values: old_values, - new_values: @timestamp.attributes - ) - - render_updated({ - timestamp: VodTimestampSerializer.render_as_hash(@timestamp) - }) - else - render_error( - message: 'Failed to update timestamp', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @timestamp.errors.as_json - ) - end - end - - def destroy - authorize @timestamp.vod_review, :update? - if @timestamp.destroy - log_user_action( - action: 'delete', - entity_type: 'VodTimestamp', - entity_id: @timestamp.id, - old_values: @timestamp.attributes - ) - - render_deleted(message: 'Timestamp deleted successfully') - else - render_error( - message: 'Failed to delete timestamp', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_vod_review - @vod_review = organization_scoped(VodReview).find(params[:vod_review_id]) - end - - def set_vod_timestamp - @timestamp = VodTimestamp.joins(:vod_review) - .where(vod_reviews: { organization: current_organization }) - .find(params[:id]) - end - - def vod_timestamp_params - params.require(:vod_timestamp).permit( - :timestamp_seconds, :category, :importance, - :title, :description, :target_type, :target_player_id - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class VodTimestampsController < ::VodReviews::Controllers::VodTimestampsController + end + end +end diff --git a/app/modules/analytics/controllers/champions_controller.rb b/app/modules/analytics/controllers/champions_controller.rb new file mode 100644 index 0000000..e1b0e8c --- /dev/null +++ b/app/modules/analytics/controllers/champions_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class ChampionsController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.where(player: player) + .group(:champion) + .select( + 'champion', + 'COUNT(*) as games_played', + 'SUM(CASE WHEN matches.victory THEN 1 ELSE 0 END) as wins', + 'AVG((kills + assists)::float / NULLIF(deaths, 0)) as avg_kda' + ) + .joins(:match) + .order('games_played DESC') + + champion_stats = stats.map do |stat| + win_rate = stat.games_played.zero? ? 0 : (stat.wins.to_f / stat.games_played) + { + champion: stat.champion, + games_played: stat.games_played, + win_rate: win_rate, + avg_kda: stat.avg_kda&.round(2) || 0, + mastery_grade: calculate_mastery_grade(win_rate, stat.avg_kda) + } + end + + champion_data = { + player: PlayerSerializer.render_as_hash(player), + champion_stats: champion_stats, + top_champions: champion_stats.take(5), + champion_diversity: { + total_champions: champion_stats.count, + highly_played: champion_stats.count { |c| c[:games_played] >= 10 }, + average_games: champion_stats.empty? ? 0 : (champion_stats.sum { |c| c[:games_played] } / champion_stats.count.to_f).round(1) + } + } + + render_success(champion_data) + end + + private + + def calculate_mastery_grade(win_rate, avg_kda) + score = (win_rate * 100 * 0.6) + ((avg_kda || 0) * 10 * 0.4) + + case score + when 80..Float::INFINITY then 'S' + when 70...80 then 'A' + when 60...70 then 'B' + when 50...60 then 'C' + else 'D' + end + end + end + end +end diff --git a/app/modules/analytics/controllers/kda_trend_controller.rb b/app/modules/analytics/controllers/kda_trend_controller.rb new file mode 100644 index 0000000..1c6cf78 --- /dev/null +++ b/app/modules/analytics/controllers/kda_trend_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class KdaTrendController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + # Get recent matches for the player + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(50) + .includes(:match) + + trend_data = { + player: PlayerSerializer.render_as_hash(player), + kda_by_match: stats.map do |stat| + kda = stat.deaths.zero? ? (stat.kills + stat.assists).to_f : ((stat.kills + stat.assists).to_f / stat.deaths) + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + kda: kda.round(2), + champion: stat.champion, + victory: stat.match.victory + } + end, + averages: { + last_10_games: calculate_kda_average(stats.limit(10)), + last_20_games: calculate_kda_average(stats.limit(20)), + overall: calculate_kda_average(stats) + } + } + + render_success(trend_data) + end + + private + + def calculate_kda_average(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + end + end +end diff --git a/app/modules/analytics/controllers/laning_controller.rb b/app/modules/analytics/controllers/laning_controller.rb new file mode 100644 index 0000000..eca0a44 --- /dev/null +++ b/app/modules/analytics/controllers/laning_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class LaningController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + laning_data = { + player: PlayerSerializer.render_as_hash(player), + cs_performance: { + avg_cs_total: stats.average('minions_killed + jungle_minions_killed')&.round(1), + avg_cs_per_min: calculate_avg_cs_per_min(stats), + best_cs_game: stats.maximum('minions_killed + jungle_minions_killed'), + worst_cs_game: stats.minimum('minions_killed + jungle_minions_killed') + }, + gold_performance: { + avg_gold: stats.average(:gold_earned)&.round(0), + best_gold_game: stats.maximum(:gold_earned), + worst_gold_game: stats.minimum(:gold_earned) + }, + cs_by_match: stats.map do |stat| + match_duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25 + cs_total = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + cs_per_min = cs_total / match_duration_mins + + { + match_id: stat.match.id, + date: stat.match.game_start, + cs_total: cs_total, + cs_per_min: cs_per_min.round(1), + gold: stat.gold_earned, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(laning_data) + end + + private + + def calculate_avg_cs_per_min(stats) + total_cs = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration + cs = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + minutes = stat.match.game_duration / 60.0 + total_cs += cs + total_minutes += minutes + end + end + + return 0 if total_minutes.zero? + (total_cs / total_minutes).round(1) + end + end + end +end diff --git a/app/modules/analytics/controllers/performance_controller.rb b/app/modules/analytics/controllers/performance_controller.rb new file mode 100644 index 0000000..9fff915 --- /dev/null +++ b/app/modules/analytics/controllers/performance_controller.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class PerformanceController < Api::V1::BaseController + def index + # Team performance analytics + matches = organization_scoped(Match) + players = organization_scoped(Player).active + + # Date range filter + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range(params[:start_date], params[:end_date]) + else + matches = matches.recent(30) # Default to last 30 days + end + + performance_data = { + overview: calculate_team_overview(matches), + win_rate_trend: calculate_win_rate_trend(matches), + performance_by_role: calculate_performance_by_role(matches), + best_performers: identify_best_performers(players, matches), + match_type_breakdown: calculate_match_type_breakdown(matches) + } + + render_success(performance_data) + end + + private + + def calculate_team_overview(matches) + stats = PlayerMatchStat.where(match: matches) + + { + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: calculate_win_rate(matches), + avg_game_duration: matches.average(:game_duration)&.round(0), + avg_kda: calculate_avg_kda(stats), + avg_kills_per_game: stats.average(:kills)&.round(1), + avg_deaths_per_game: stats.average(:deaths)&.round(1), + avg_assists_per_game: stats.average(:assists)&.round(1), + avg_gold_per_game: stats.average(:gold_earned)&.round(0), + avg_damage_per_game: stats.average(:total_damage_dealt)&.round(0), + avg_vision_score: stats.average(:vision_score)&.round(1) + } + end + + def calculate_win_rate_trend(matches) + # Calculate win rate for each week + matches.group_by { |m| m.game_start.beginning_of_week }.map do |week, week_matches| + wins = week_matches.count(&:victory?) + total = week_matches.size + win_rate = total.zero? ? 0 : ((wins.to_f / total) * 100).round(1) + + { + week: week.strftime('%Y-%m-%d'), + matches: total, + wins: wins, + losses: total - wins, + win_rate: win_rate + } + end.sort_by { |d| d[:week] } + end + + def calculate_performance_by_role(matches) + stats = PlayerMatchStat.joins(:player).where(match: matches) + + stats.group('players.role').select( + 'players.role', + 'COUNT(*) as games', + 'AVG(player_match_stats.kills) as avg_kills', + 'AVG(player_match_stats.deaths) as avg_deaths', + 'AVG(player_match_stats.assists) as avg_assists', + 'AVG(player_match_stats.gold_earned) as avg_gold', + 'AVG(player_match_stats.total_damage_dealt) as avg_damage', + 'AVG(player_match_stats.vision_score) as avg_vision' + ).map do |stat| + { + role: stat.role, + games: stat.games, + avg_kda: { + kills: stat.avg_kills&.round(1) || 0, + deaths: stat.avg_deaths&.round(1) || 0, + assists: stat.avg_assists&.round(1) || 0 + }, + avg_gold: stat.avg_gold&.round(0) || 0, + avg_damage: stat.avg_damage&.round(0) || 0, + avg_vision: stat.avg_vision&.round(1) || 0 + } + end + end + + def identify_best_performers(players, matches) + players.map do |player| + stats = PlayerMatchStat.where(player: player, match: matches) + next if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games: stats.count, + avg_kda: calculate_avg_kda(stats), + avg_performance_score: stats.average(:performance_score)&.round(1) || 0, + mvp_count: stats.joins(:match).where(matches: { victory: true }).count + } + end.compact.sort_by { |p| -p[:avg_performance_score] }.take(5) + end + + def calculate_match_type_breakdown(matches) + matches.group(:match_type).select( + 'match_type', + 'COUNT(*) as total', + 'SUM(CASE WHEN victory THEN 1 ELSE 0 END) as wins' + ).map do |stat| + win_rate = stat.total.zero? ? 0 : ((stat.wins.to_f / stat.total) * 100).round(1) + { + match_type: stat.match_type, + total: stat.total, + wins: stat.wins, + losses: stat.total - stat.wins, + win_rate: win_rate + } + end + end + + def calculate_win_rate(matches) + return 0 if matches.empty? + ((matches.victories.count.to_f / matches.count) * 100).round(1) + end + + def calculate_avg_kda(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + end + end +end diff --git a/app/modules/analytics/controllers/team_comparison_controller.rb b/app/modules/analytics/controllers/team_comparison_controller.rb new file mode 100644 index 0000000..39d2b9f --- /dev/null +++ b/app/modules/analytics/controllers/team_comparison_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class TeamComparisonController < Api::V1::BaseController + def index + players = organization_scoped(Player).active.includes(:player_match_stats) + + matches = organization_scoped(Match) + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range(params[:start_date], params[:end_date]) + else + matches = matches.recent(30) + end + + comparison_data = { + players: players.map do |player| + stats = PlayerMatchStat.where(player: player, match: matches) + next if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games_played: stats.count, + kda: calculate_kda(stats), + avg_damage: stats.average(:total_damage_dealt)&.round(0) || 0, + avg_gold: stats.average(:gold_earned)&.round(0) || 0, + avg_cs: stats.average('minions_killed + jungle_minions_killed')&.round(1) || 0, + avg_vision_score: stats.average(:vision_score)&.round(1) || 0, + avg_performance_score: stats.average(:performance_score)&.round(1) || 0, + multikills: { + double: stats.sum(:double_kills), + triple: stats.sum(:triple_kills), + quadra: stats.sum(:quadra_kills), + penta: stats.sum(:penta_kills) + } + } + end.compact.sort_by { |p| -p[:avg_performance_score] }, + team_averages: calculate_team_averages(matches), + role_rankings: calculate_role_rankings(players, matches) + } + + render_success(comparison_data) + end + + private + + def calculate_kda(stats) + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + def calculate_team_averages(matches) + all_stats = PlayerMatchStat.where(match: matches) + + { + avg_kda: calculate_kda(all_stats), + avg_damage: all_stats.average(:total_damage_dealt)&.round(0) || 0, + avg_gold: all_stats.average(:gold_earned)&.round(0) || 0, + avg_cs: all_stats.average('minions_killed + jungle_minions_killed')&.round(1) || 0, + avg_vision_score: all_stats.average(:vision_score)&.round(1) || 0 + } + end + + def calculate_role_rankings(players, matches) + rankings = {} + + %w[top jungle mid adc support].each do |role| + role_players = players.where(role: role) + role_data = role_players.map do |player| + stats = PlayerMatchStat.where(player: player, match: matches) + next if stats.empty? + + { + player_id: player.id, + summoner_name: player.summoner_name, + avg_performance: stats.average(:performance_score)&.round(1) || 0, + games: stats.count + } + end.compact.sort_by { |p| -p[:avg_performance] } + + rankings[role] = role_data + end + + rankings + end + end + end +end diff --git a/app/modules/analytics/controllers/teamfights_controller.rb b/app/modules/analytics/controllers/teamfights_controller.rb new file mode 100644 index 0000000..72f2390 --- /dev/null +++ b/app/modules/analytics/controllers/teamfights_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class TeamfightsController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + teamfight_data = { + player: PlayerSerializer.render_as_hash(player), + damage_performance: { + avg_damage_dealt: stats.average(:total_damage_dealt)&.round(0), + avg_damage_taken: stats.average(:total_damage_taken)&.round(0), + best_damage_game: stats.maximum(:total_damage_dealt), + avg_damage_per_min: calculate_avg_damage_per_min(stats) + }, + participation: { + avg_kills: stats.average(:kills)&.round(1), + avg_assists: stats.average(:assists)&.round(1), + avg_deaths: stats.average(:deaths)&.round(1), + multikill_stats: { + double_kills: stats.sum(:double_kills), + triple_kills: stats.sum(:triple_kills), + quadra_kills: stats.sum(:quadra_kills), + penta_kills: stats.sum(:penta_kills) + } + }, + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + damage_dealt: stat.total_damage_dealt, + damage_taken: stat.total_damage_taken, + multikills: stat.double_kills + stat.triple_kills + stat.quadra_kills + stat.penta_kills, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(teamfight_data) + end + + private + + def calculate_avg_damage_per_min(stats) + total_damage = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.total_damage_dealt + total_damage += stat.total_damage_dealt + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + (total_damage / total_minutes).round(0) + end + end + end +end diff --git a/app/modules/analytics/controllers/vision_controller.rb b/app/modules/analytics/controllers/vision_controller.rb new file mode 100644 index 0000000..3fd32f8 --- /dev/null +++ b/app/modules/analytics/controllers/vision_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class VisionController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + vision_data = { + player: PlayerSerializer.render_as_hash(player), + vision_stats: { + avg_vision_score: stats.average(:vision_score)&.round(1), + avg_wards_placed: stats.average(:wards_placed)&.round(1), + avg_wards_killed: stats.average(:wards_killed)&.round(1), + best_vision_game: stats.maximum(:vision_score), + total_wards_placed: stats.sum(:wards_placed), + total_wards_killed: stats.sum(:wards_killed) + }, + vision_per_min: calculate_avg_vision_per_min(stats), + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + vision_score: stat.vision_score, + wards_placed: stat.wards_placed, + wards_killed: stat.wards_killed, + champion: stat.champion, + role: stat.role, + victory: stat.match.victory + } + end, + role_comparison: calculate_role_comparison(player) + } + + render_success(vision_data) + end + + private + + def calculate_avg_vision_per_min(stats) + total_vision = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.vision_score + total_vision += stat.vision_score + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + (total_vision / total_minutes).round(2) + end + + def calculate_role_comparison(player) + # Compare player's vision score to team average for same role + team_stats = PlayerMatchStat.joins(:player) + .where(players: { organization: current_organization, role: player.role }) + .where.not(players: { id: player.id }) + + player_stats = PlayerMatchStat.where(player: player) + + { + player_avg: player_stats.average(:vision_score)&.round(1) || 0, + role_avg: team_stats.average(:vision_score)&.round(1) || 0, + percentile: calculate_percentile(player_stats.average(:vision_score), team_stats) + } + end + + def calculate_percentile(player_avg, team_stats) + return 0 if player_avg.nil? || team_stats.empty? + + all_averages = team_stats.group(:player_id).average(:vision_score).values + all_averages << player_avg + all_averages.sort! + + rank = all_averages.index(player_avg) + 1 + ((rank.to_f / all_averages.size) * 100).round(0) + end + end + end +end diff --git a/app/modules/authentication/controllers/auth_controller.rb b/app/modules/authentication/controllers/auth_controller.rb index 5a6da5b..58c12be 100644 --- a/app/modules/authentication/controllers/auth_controller.rb +++ b/app/modules/authentication/controllers/auth_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Authentication module Controllers class AuthController < Api::V1::BaseController @@ -10,18 +12,22 @@ def register user = create_user!(organization) tokens = Authentication::Services::JwtService.generate_tokens(user) - log_user_action( + AuditLog.create!( + organization: organization, + user: user, action: 'register', entity_type: 'User', - entity_id: user.id + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent ) UserMailer.welcome(user).deliver_later render_created( { - user: UserSerializer.new(user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(organization).serializable_hash[:data][:attributes], + user: JSON.parse(UserSerializer.render(user)), + organization: JSON.parse(OrganizationSerializer.render(organization)), **tokens }, message: 'Registration successful' @@ -41,16 +47,20 @@ def login tokens = Authentication::Services::JwtService.generate_tokens(user) user.update_last_login! - log_user_action( + AuditLog.create!( + organization: user.organization, + user: user, action: 'login', entity_type: 'User', - entity_id: user.id + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent ) render_success( { - user: UserSerializer.new(user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(user.organization).serializable_hash[:data][:attributes], + user: JSON.parse(UserSerializer.render(user)), + organization: JSON.parse(OrganizationSerializer.render(user.organization)), **tokens }, message: 'Login successful' @@ -90,6 +100,7 @@ def refresh # POST /api/v1/auth/logout def logout + # Blacklist the current access token token = request.headers['Authorization']&.split(' ')&.last Authentication::Services::JwtService.blacklist_token(token) if token @@ -124,10 +135,14 @@ def forgot_password UserMailer.password_reset(user, reset_token).deliver_later - log_user_action( + AuditLog.create!( + organization: user.organization, + user: user, action: 'password_reset_requested', entity_type: 'User', - entity_id: user.id + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent ) end @@ -169,10 +184,14 @@ def reset_password UserMailer.password_reset_confirmation(user).deliver_later - log_user_action( + AuditLog.create!( + organization: user.organization, + user: user, action: 'password_reset_completed', entity_type: 'User', - entity_id: user.id + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent ) render_success({}, message: 'Password reset successful') @@ -189,8 +208,8 @@ def reset_password def me render_success( { - user: UserSerializer.new(current_user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(current_organization).serializable_hash[:data][:attributes] + user: JSON.parse(UserSerializer.render(current_user)), + organization: JSON.parse(OrganizationSerializer.render(current_organization)) } ) end @@ -225,7 +244,6 @@ def organization_params def user_params params.require(:user).permit(:email, :password, :full_name, :timezone, :language) end - end end -end \ No newline at end of file +end diff --git a/app/modules/authentication/serializers/organization_serializer.rb b/app/modules/authentication/serializers/organization_serializer.rb new file mode 100644 index 0000000..1bcf09d --- /dev/null +++ b/app/modules/authentication/serializers/organization_serializer.rb @@ -0,0 +1,53 @@ +class OrganizationSerializer < Blueprinter::Base + + identifier :id + + fields :name, :slug, :region, :tier, :subscription_plan, :subscription_status, + :logo_url, :settings, :created_at, :updated_at + + field :region_display do |org| + region_names = { + 'BR' => 'Brazil', + 'NA' => 'North America', + 'EUW' => 'Europe West', + 'EUNE' => 'Europe Nordic & East', + 'KR' => 'Korea', + 'LAN' => 'Latin America North', + 'LAS' => 'Latin America South', + 'OCE' => 'Oceania', + 'RU' => 'Russia', + 'TR' => 'Turkey', + 'JP' => 'Japan' + } + + region_names[org.region] || org.region + end + + field :tier_display do |org| + if org.tier.blank? + 'Not set' + else + org.tier.humanize + end + end + + field :subscription_display do |org| + if org.subscription_plan.blank? + 'Free' + else + plan = org.subscription_plan.humanize + status = org.subscription_status&.humanize || 'Active' + "#{plan} (#{status})" + end + end + + field :statistics do |org| + { + total_players: org.players.count, + active_players: org.players.active.count, + total_matches: org.matches.count, + recent_matches: org.matches.recent(30).count, + total_users: org.users.count + } + end +end \ No newline at end of file diff --git a/app/modules/authentication/serializers/user_serializer.rb b/app/modules/authentication/serializers/user_serializer.rb new file mode 100644 index 0000000..478408f --- /dev/null +++ b/app/modules/authentication/serializers/user_serializer.rb @@ -0,0 +1,45 @@ +class UserSerializer < Blueprinter::Base + + identifier :id + + fields :email, :full_name, :role, :avatar_url, :timezone, :language, + :notifications_enabled, :notification_preferences, :last_login_at, + :created_at, :updated_at + + field :role_display do |user| + user.full_role_name + end + + field :permissions do |user| + { + can_manage_users: user.can_manage_users?, + can_manage_players: user.can_manage_players?, + can_view_analytics: user.can_view_analytics?, + is_admin_or_owner: user.admin_or_owner? + } + end + + field :last_login_display do |user| + user.last_login_at ? time_ago_in_words(user.last_login_at) : 'Never' + end + + def self.time_ago_in_words(time) + if time.nil? + 'Never' + else + diff = Time.current - time + case diff + when 0...60 + "#{diff.to_i} seconds ago" + when 60...3600 + "#{(diff / 60).to_i} minutes ago" + when 3600...86400 + "#{(diff / 3600).to_i} hours ago" + when 86400...2592000 + "#{(diff / 86400).to_i} days ago" + else + time.strftime('%B %d, %Y') + end + end + end +end \ No newline at end of file diff --git a/app/modules/dashboard/controllers/dashboard_controller.rb b/app/modules/dashboard/controllers/dashboard_controller.rb new file mode 100644 index 0000000..26ba05b --- /dev/null +++ b/app/modules/dashboard/controllers/dashboard_controller.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Dashboard + module Controllers + class DashboardController < Api::V1::BaseController + def index + dashboard_data = { + stats: calculate_stats, + recent_matches: recent_matches_data, + upcoming_events: upcoming_events_data, + active_goals: active_goals_data, + roster_status: roster_status_data + } + + render_success(dashboard_data) + end + + def stats + cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" + cached_stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) { calculate_stats } + render_success(cached_stats) + end + + def activities + recent_activities = fetch_recent_activities + + render_success({ + activities: recent_activities, + count: recent_activities.size + }) + end + + def schedule + events = organization_scoped(Schedule) + .where('start_time >= ?', Time.current) + .order(start_time: :asc) + .limit(10) + + render_success({ + events: ScheduleSerializer.render_as_hash(events), + count: events.size + }) + end + + private + + def calculate_stats + matches = organization_scoped(Match).recent(30) + players = organization_scoped(Player).active + + { + total_players: players.count, + active_players: players.where(status: 'active').count, + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: calculate_win_rate(matches), + recent_form: calculate_recent_form(matches.order(game_start: :desc).limit(5)), + avg_kda: calculate_average_kda(matches), + active_goals: organization_scoped(TeamGoal).active.count, + completed_goals: organization_scoped(TeamGoal).where(status: 'completed').count, + upcoming_matches: organization_scoped(Schedule).where('start_time >= ? AND event_type = ?', Time.current, 'match').count + } + end + + def calculate_win_rate(matches) + return 0 if matches.empty? + ((matches.victories.count.to_f / matches.count) * 100).round(1) + end + + def calculate_recent_form(matches) + matches.map { |m| m.victory? ? 'W' : 'L' }.join('') + end + + def calculate_average_kda(matches) + stats = PlayerMatchStat.where(match: matches) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + def recent_matches_data + matches = organization_scoped(Match) + .order(game_start: :desc) + .limit(5) + + MatchSerializer.render_as_hash(matches) + end + + def upcoming_events_data + events = organization_scoped(Schedule) + .where('start_time >= ?', Time.current) + .order(start_time: :asc) + .limit(5) + + ScheduleSerializer.render_as_hash(events) + end + + def active_goals_data + goals = organization_scoped(TeamGoal) + .active + .order(end_date: :asc) + .limit(5) + + TeamGoalSerializer.render_as_hash(goals) + end + + def roster_status_data + players = organization_scoped(Player).includes(:champion_pools) + + # Order by role to ensure consistent order in by_role hash + by_role_ordered = players.ordered_by_role.group(:role).count + + { + by_role: by_role_ordered, + by_status: players.group(:status).count, + contracts_expiring: players.contracts_expiring_soon.count + } + end + + def fetch_recent_activities + # Fetch recent audit logs and format them + activities = AuditLog + .where(organization: current_organization) + .order(created_at: :desc) + .limit(20) + + activities.map do |log| + { + id: log.id, + action: log.action, + entity_type: log.entity_type, + entity_id: log.entity_id, + user: log.user&.email, + timestamp: log.created_at, + changes: summarize_changes(log) + } + end + end + + def summarize_changes(log) + return nil unless log.new_values.present? + + # Only show important field changes + important_fields = %w[status role summoner_name title victory] + changes = log.new_values.slice(*important_fields) + + return nil if changes.empty? + changes + end + end + end +end diff --git a/app/modules/matches/controllers/matches_controller.rb b/app/modules/matches/controllers/matches_controller.rb new file mode 100644 index 0000000..32e0fb8 --- /dev/null +++ b/app/modules/matches/controllers/matches_controller.rb @@ -0,0 +1,259 @@ +class Api::V1::MatchesController < Api::V1::BaseController + before_action :set_match, only: [:show, :update, :destroy, :stats] + + def index + matches = organization_scoped(Match).includes(:player_match_stats, :players) + + matches = matches.by_type(params[:match_type]) if params[:match_type].present? + matches = matches.victories if params[:result] == 'victory' + matches = matches.defeats if params[:result] == 'defeat' + + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range(params[:start_date], params[:end_date]) + elsif params[:days].present? + matches = matches.recent(params[:days].to_i) + end + + matches = matches.with_opponent(params[:opponent]) if params[:opponent].present? + + if params[:tournament].present? + matches = matches.where('tournament_name ILIKE ?', "%#{params[:tournament]}%") + end + + sort_by = params[:sort_by] || 'game_start' + sort_order = params[:sort_order] || 'desc' + matches = matches.order("#{sort_by} #{sort_order}") + + result = paginate(matches) + + render_success({ + matches: MatchSerializer.render_as_hash(result[:data]), + pagination: result[:pagination], + summary: calculate_matches_summary(matches) + }) + end + + def show + match_data = MatchSerializer.render_as_hash(@match) + player_stats = PlayerMatchStatSerializer.render_as_hash( + @match.player_match_stats.includes(:player) + ) + + render_success({ + match: match_data, + player_stats: player_stats, + team_composition: @match.team_composition, + mvp: @match.mvp_player ? PlayerSerializer.render_as_hash(@match.mvp_player) : nil + }) + end + + def create + match = organization_scoped(Match).new(match_params) + match.organization = current_organization + + if match.save + log_user_action( + action: 'create', + entity_type: 'Match', + entity_id: match.id, + new_values: match.attributes + ) + + render_created({ + match: MatchSerializer.render_as_hash(match) + }, message: 'Match created successfully') + else + render_error( + message: 'Failed to create match', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: match.errors.as_json + ) + end + end + + def update + old_values = @match.attributes.dup + + if @match.update(match_params) + log_user_action( + action: 'update', + entity_type: 'Match', + entity_id: @match.id, + old_values: old_values, + new_values: @match.attributes + ) + + render_updated({ + match: MatchSerializer.render_as_hash(@match) + }) + else + render_error( + message: 'Failed to update match', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @match.errors.as_json + ) + end + end + + def destroy + if @match.destroy + log_user_action( + action: 'delete', + entity_type: 'Match', + entity_id: @match.id, + old_values: @match.attributes + ) + + render_deleted(message: 'Match deleted successfully') + else + render_error( + message: 'Failed to delete match', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + def stats + stats = @match.player_match_stats.includes(:player) + + stats_data = { + match: MatchSerializer.render_as_hash(@match), + team_stats: calculate_team_stats(stats), + player_stats: stats.map do |stat| + player_data = PlayerMatchStatSerializer.render_as_hash(stat) + player_data[:player] = PlayerSerializer.render_as_hash(stat.player) + player_data + end, + comparison: { + total_gold: stats.sum(:gold_earned), + total_damage: stats.sum(:total_damage_dealt), + total_vision_score: stats.sum(:vision_score), + avg_kda: calculate_avg_kda(stats) + } + } + + render_success(stats_data) + end + + def import + player_id = params[:player_id] + count = params[:count]&.to_i || 20 + + unless player_id.present? + return render_error( + message: 'player_id is required', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + + player = organization_scoped(Player).find(player_id) + + unless player.riot_puuid.present? + return render_error( + message: 'Player does not have a Riot PUUID. Please sync player from Riot first.', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + + begin + riot_service = RiotApiService.new + region = player.region || 'BR' + + match_ids = riot_service.get_match_history( + puuid: player.riot_puuid, + region: region, + count: count + ) + + imported_count = 0 + match_ids.each do |match_id| + next if Match.exists?(riot_match_id: match_id) + + SyncMatchJob.perform_later(match_id, current_organization.id, region) + imported_count += 1 + end + + render_success({ + message: "Queued #{imported_count} matches for import", + total_matches_found: match_ids.count, + already_imported: match_ids.count - imported_count, + player: PlayerSerializer.render_as_hash(player) + }) + + rescue RiotApiService::RiotApiError => e + render_error( + message: "Failed to fetch matches from Riot API: #{e.message}", + code: 'RIOT_API_ERROR', + status: :bad_gateway + ) + rescue StandardError => e + render_error( + message: "Failed to import matches: #{e.message}", + code: 'IMPORT_ERROR', + status: :internal_server_error + ) + end + end + + private + + def set_match + @match = organization_scoped(Match).find(params[:id]) + end + + def match_params + params.require(:match).permit( + :match_type, :game_start, :game_end, :game_duration, + :riot_match_id, :patch_version, :tournament_name, :stage, + :opponent_name, :opponent_tag, :victory, + :our_side, :our_score, :opponent_score, + :first_blood, :first_tower, :first_baron, :first_dragon, + :total_kills, :total_deaths, :total_assists, :total_gold, + :vod_url, :replay_file_url, :notes + ) + end + + def calculate_matches_summary(matches) + { + total: matches.count, + victories: matches.victories.count, + defeats: matches.defeats.count, + win_rate: calculate_win_rate(matches), + by_type: matches.group(:match_type).count, + avg_duration: matches.average(:game_duration)&.round(0) + } + end + + def calculate_win_rate(matches) + return 0 if matches.empty? + ((matches.victories.count.to_f / matches.count) * 100).round(1) + end + + def calculate_team_stats(stats) + { + total_kills: stats.sum(:kills), + total_deaths: stats.sum(:deaths), + total_assists: stats.sum(:assists), + total_gold: stats.sum(:gold_earned), + total_damage: stats.sum(:total_damage_dealt), + total_cs: stats.sum(:minions_killed), + total_vision_score: stats.sum(:vision_score) + } + end + + def calculate_avg_kda(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end +end diff --git a/app/modules/matches/jobs/sync_match_job.rb b/app/modules/matches/jobs/sync_match_job.rb new file mode 100644 index 0000000..26ac5f5 --- /dev/null +++ b/app/modules/matches/jobs/sync_match_job.rb @@ -0,0 +1,128 @@ +class SyncMatchJob < ApplicationJob + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(match_id, organization_id, region = 'BR') + organization = Organization.find(organization_id) + riot_service = RiotApiService.new + + match_data = riot_service.get_match_details( + match_id: match_id, + region: region + ) + + match = Match.find_by(riot_match_id: match_data[:match_id]) + if match.present? + Rails.logger.info("Match #{match_id} already exists") + return + end + + match = create_match_record(match_data, organization) + + create_player_match_stats(match, match_data[:participants], organization) + + Rails.logger.info("Successfully synced match #{match_id}") + + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Match not found in Riot API: #{match_id} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync match #{match_id}: #{e.message}") + raise + end + + private + + def create_match_record(match_data, organization) + Match.create!( + organization: organization, + riot_match_id: match_data[:match_id], + match_type: determine_match_type(match_data[:game_mode]), + game_start: match_data[:game_creation], + game_end: match_data[:game_creation] + match_data[:game_duration].seconds, + game_duration: match_data[:game_duration], + patch_version: match_data[:game_version], + victory: determine_team_victory(match_data[:participants], organization) + ) + end + + def create_player_match_stats(match, participants, organization) + participants.each do |participant_data| + player = organization.players.find_by(riot_puuid: participant_data[:puuid]) + next unless player + + PlayerMatchStat.create!( + match: match, + player: player, + role: normalize_role(participant_data[:role]), + champion: participant_data[:champion_name], + kills: participant_data[:kills], + deaths: participant_data[:deaths], + assists: participant_data[:assists], + gold_earned: participant_data[:gold_earned], + total_damage_dealt: participant_data[:total_damage_dealt], + total_damage_taken: participant_data[:total_damage_taken], + minions_killed: participant_data[:minions_killed], + jungle_minions_killed: participant_data[:neutral_minions_killed], + vision_score: participant_data[:vision_score], + wards_placed: participant_data[:wards_placed], + wards_killed: participant_data[:wards_killed], + champion_level: participant_data[:champion_level], + first_blood_kill: participant_data[:first_blood_kill], + double_kills: participant_data[:double_kills], + triple_kills: participant_data[:triple_kills], + quadra_kills: participant_data[:quadra_kills], + penta_kills: participant_data[:penta_kills], + performance_score: calculate_performance_score(participant_data) + ) + end + end + + def determine_match_type(game_mode) + case game_mode.upcase + when 'CLASSIC' then 'official' + when 'ARAM' then 'scrim' + else 'scrim' + end + end + + def determine_team_victory(participants, organization) + our_player_puuids = organization.players.pluck(:riot_puuid).compact + our_participants = participants.select { |p| our_player_puuids.include?(p[:puuid]) } + + return nil if our_participants.empty? + + our_participants.first[:win] + end + + def normalize_role(role) + role_mapping = { + 'top' => 'top', + 'jungle' => 'jungle', + 'middle' => 'mid', + 'mid' => 'mid', + 'bottom' => 'adc', + 'adc' => 'adc', + 'utility' => 'support', + 'support' => 'support' + } + + role_mapping[role&.downcase] || 'mid' + end + + def calculate_performance_score(participant_data) + # Simple performance score calculation + # This can be made more sophisticated + # future work + kda = participant_data[:deaths].zero? ? + (participant_data[:kills] + participant_data[:assists]).to_f : + (participant_data[:kills] + participant_data[:assists]).to_f / participant_data[:deaths] + + base_score = kda * 10 + damage_score = (participant_data[:total_damage_dealt] / 1000.0) + vision_score = participant_data[:vision_score] || 0 + + (base_score + damage_score * 0.1 + vision_score).round(2) + end +end diff --git a/app/modules/matches/serializers/match_serializer.rb b/app/modules/matches/serializers/match_serializer.rb new file mode 100644 index 0000000..f4c3a08 --- /dev/null +++ b/app/modules/matches/serializers/match_serializer.rb @@ -0,0 +1,38 @@ +class MatchSerializer < Blueprinter::Base + identifier :id + + fields :match_type, :game_start, :game_end, :game_duration, + :riot_match_id, :game_version, + :opponent_name, :opponent_tag, :victory, + :our_side, :our_score, :opponent_score, + :our_towers, :opponent_towers, :our_dragons, :opponent_dragons, + :our_barons, :opponent_barons, :our_inhibitors, :opponent_inhibitors, + :vod_url, :replay_file_url, :notes, + :created_at, :updated_at + + field :result do |match| + match.result_text + end + + field :duration_formatted do |match| + match.duration_formatted + end + + field :score_display do |match| + match.score_display + end + + field :kda_summary do |match| + match.kda_summary + end + + field :has_replay do |match| + match.has_replay? + end + + field :has_vod do |match| + match.has_vod? + end + + association :organization, blueprint: OrganizationSerializer +end diff --git a/app/modules/matches/serializers/player_match_stat_serializer.rb b/app/modules/matches/serializers/player_match_stat_serializer.rb new file mode 100644 index 0000000..d9a0d38 --- /dev/null +++ b/app/modules/matches/serializers/player_match_stat_serializer.rb @@ -0,0 +1,23 @@ +class PlayerMatchStatSerializer < Blueprinter::Base + identifier :id + + fields :role, :champion, :kills, :deaths, :assists, + :gold_earned, :total_damage_dealt, :total_damage_taken, + :minions_killed, :jungle_minions_killed, + :vision_score, :wards_placed, :wards_killed, + :champion_level, :first_blood_kill, :double_kills, + :triple_kills, :quadra_kills, :penta_kills, + :performance_score, :created_at, :updated_at + + field :kda do |stat| + deaths = stat.deaths.zero? ? 1 : stat.deaths + ((stat.kills + stat.assists).to_f / deaths).round(2) + end + + field :cs_total do |stat| + (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + end + + association :player, blueprint: PlayerSerializer + association :match, blueprint: MatchSerializer +end diff --git a/app/modules/players/controllers/players_controller.rb b/app/modules/players/controllers/players_controller.rb new file mode 100644 index 0000000..9ce30bc --- /dev/null +++ b/app/modules/players/controllers/players_controller.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +module Players + module Controllers + # Controller for managing players within an organization + # Business logic extracted to Services for better organization + class PlayersController < Api::V1::BaseController + before_action :set_player, only: [:show, :update, :destroy, :stats, :matches, :sync_from_riot] + + # GET /api/v1/players + def index + players = organization_scoped(Player).includes(:champion_pools) + + players = players.by_role(params[:role]) if params[:role].present? + players = players.by_status(params[:status]) if params[:status].present? + + if params[:search].present? + search_term = "%#{params[:search]}%" + players = players.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) + end + + result = paginate(players.ordered_by_role.order(:summoner_name)) + + render_success({ + players: PlayerSerializer.render_as_hash(result[:data]), + pagination: result[:pagination] + }) + end + + # GET /api/v1/players/:id + def show + render_success({ + player: PlayerSerializer.render_as_hash(@player) + }) + end + + # POST /api/v1/players + def create + player = organization_scoped(Player).new(player_params) + player.organization = current_organization + + if player.save + log_user_action( + action: 'create', + entity_type: 'Player', + entity_id: player.id, + new_values: player.attributes + ) + + render_created({ + player: PlayerSerializer.render_as_hash(player) + }, message: 'Player created successfully') + else + render_error( + message: 'Failed to create player', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: player.errors.as_json + ) + end + end + + # PATCH/PUT /api/v1/players/:id + def update + old_values = @player.attributes.dup + + if @player.update(player_params) + log_user_action( + action: 'update', + entity_type: 'Player', + entity_id: @player.id, + old_values: old_values, + new_values: @player.attributes + ) + + render_updated({ + player: PlayerSerializer.render_as_hash(@player) + }) + else + render_error( + message: 'Failed to update player', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @player.errors.as_json + ) + end + end + + # DELETE /api/v1/players/:id + def destroy + if @player.destroy + log_user_action( + action: 'delete', + entity_type: 'Player', + entity_id: @player.id, + old_values: @player.attributes + ) + + render_deleted(message: 'Player deleted successfully') + else + render_error( + message: 'Failed to delete player', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + # GET /api/v1/players/:id/stats + def stats + stats_service = Players::Services::StatsService.new(@player) + stats_data = stats_service.calculate_stats + + render_success({ + player: PlayerSerializer.render_as_hash(stats_data[:player]), + overall: stats_data[:overall], + recent_form: stats_data[:recent_form], + champion_pool: ChampionPoolSerializer.render_as_hash(stats_data[:champion_pool]), + performance_by_role: stats_data[:performance_by_role] + }) + end + + # GET /api/v1/players/:id/matches + def matches + matches = @player.matches + .includes(:player_match_stats) + .order(game_start: :desc) + + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range(params[:start_date], params[:end_date]) + end + + result = paginate(matches) + + matches_with_stats = result[:data].map do |match| + player_stat = match.player_match_stats.find_by(player: @player) + { + match: MatchSerializer.render_as_hash(match), + player_stats: player_stat ? PlayerMatchStatSerializer.render_as_hash(player_stat) : nil + } + end + + render_success({ + matches: matches_with_stats, + pagination: result[:pagination] + }) + end + + # POST /api/v1/players/import + def import + summoner_name = params[:summoner_name]&.strip + role = params[:role] + region = params[:region] || 'br1' + + unless summoner_name.present? && role.present? + return render_error( + message: 'Summoner name and role are required', + code: 'MISSING_PARAMETERS', + status: :unprocessable_entity, + details: { + hint: 'Format: "GameName#TAG" or "GameName-TAG" (e.g., "Faker#KR1" or "Faker-KR1")' + } + ) + end + + unless %w[top jungle mid adc support].include?(role) + return render_error( + message: 'Invalid role', + code: 'INVALID_ROLE', + status: :unprocessable_entity + ) + end + + existing_player = organization_scoped(Player).find_by(summoner_name: summoner_name) + if existing_player + return render_error( + message: 'Player already exists in your organization', + code: 'PLAYER_EXISTS', + status: :unprocessable_entity + ) + end + + result = Players::Services::RiotSyncService.import( + summoner_name: summoner_name, + role: role, + region: region, + organization: current_organization + ) + + if result[:success] + log_user_action( + action: 'import_riot', + entity_type: 'Player', + entity_id: result[:player].id, + new_values: result[:player].attributes + ) + + render_created({ + player: PlayerSerializer.render_as_hash(result[:player]), + message: "Player #{result[:summoner_name]} imported successfully from Riot API" + }) + else + render_error( + message: "Failed to import from Riot API: #{result[:error]}", + code: result[:code] || 'IMPORT_ERROR', + status: :service_unavailable + ) + end + end + + # POST /api/v1/players/:id/sync_from_riot + def sync_from_riot + service = Players::Services::RiotSyncService.new(@player, region: params[:region]) + result = service.sync + + if result[:success] + log_user_action( + action: 'sync_riot', + entity_type: 'Player', + entity_id: @player.id, + new_values: @player.attributes + ) + + render_success({ + player: PlayerSerializer.render_as_hash(@player.reload), + message: 'Player synced successfully from Riot API' + }) + else + render_error( + message: "Failed to sync with Riot API: #{result[:error]}", + code: result[:code] || 'SYNC_ERROR', + status: :service_unavailable + ) + end + end + + # GET /api/v1/players/search_riot_id + def search_riot_id + summoner_name = params[:summoner_name]&.strip + region = params[:region] || 'br1' + + unless summoner_name.present? + return render_error( + message: 'Summoner name is required', + code: 'MISSING_PARAMETERS', + status: :unprocessable_entity + ) + end + + result = Players::Services::RiotSyncService.search_riot_id(summoner_name, region: region) + + if result[:success] && result[:found] + render_success(result.except(:success)) + elsif result[:success] && !result[:found] + render_error( + message: result[:error], + code: 'PLAYER_NOT_FOUND', + status: :not_found, + details: { + game_name: result[:game_name], + tried_tags: result[:tried_tags], + hint: 'Please verify the exact Riot ID in the League client (Settings > Account > Riot ID)' + } + ) + else + render_error( + message: result[:error], + code: result[:code] || 'SEARCH_ERROR', + status: :service_unavailable + ) + end + end + + # POST /api/v1/players/bulk_sync + def bulk_sync + status = params[:status] || 'active' + + players = organization_scoped(Player).where(status: status) + + if players.empty? + return render_error( + message: "No #{status} players found to sync", + code: 'NO_PLAYERS_FOUND', + status: :not_found + ) + end + + riot_api_key = ENV['RIOT_API_KEY'] + unless riot_api_key.present? + return render_error( + message: 'Riot API key not configured', + code: 'RIOT_API_NOT_CONFIGURED', + status: :service_unavailable + ) + end + + players.update_all(sync_status: 'syncing') + + players.each do |player| + SyncPlayerFromRiotJob.perform_later(player.id) + end + + render_success({ + message: "#{players.count} players queued for sync", + players_count: players.count + }) + end + + private + + def set_player + @player = organization_scoped(Player).find(params[:id]) + end + + def player_params + # :role refers to in-game position (top/jungle/mid/adc/support), not user role + # nosemgrep + params.require(:player).permit( + :summoner_name, :real_name, :role, :region, :status, :jersey_number, + :birth_date, :country, :nationality, + :contract_start_date, :contract_end_date, + :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, + :solo_queue_wins, :solo_queue_losses, + :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, + :peak_tier, :peak_rank, :peak_season, + :riot_puuid, :riot_summoner_id, + :twitter_handle, :twitch_channel, :instagram_handle, + :notes + ) + end + end + end +end diff --git a/app/modules/players/jobs/sync_player_from_riot_job.rb b/app/modules/players/jobs/sync_player_from_riot_job.rb new file mode 100644 index 0000000..e2863c8 --- /dev/null +++ b/app/modules/players/jobs/sync_player_from_riot_job.rb @@ -0,0 +1,139 @@ +class SyncPlayerFromRiotJob < ApplicationJob + queue_as :default + + def perform(player_id) + player = Player.find(player_id) + + unless player.riot_puuid.present? || player.summoner_name.present? + player.update(sync_status: 'error', last_sync_at: Time.current) + Rails.logger.error "Player #{player_id} missing Riot info" + return + end + + riot_api_key = ENV['RIOT_API_KEY'] + unless riot_api_key.present? + player.update(sync_status: 'error', last_sync_at: Time.current) + Rails.logger.error "Riot API key not configured" + return + end + + begin + region = player.region.presence&.downcase || 'br1' + + if player.riot_puuid.present? + summoner_data = fetch_summoner_by_puuid(player.riot_puuid, region, riot_api_key) + else + summoner_data = fetch_summoner_by_name(player.summoner_name, region, riot_api_key) + end + + ranked_data = fetch_ranked_stats(summoner_data['id'], region, riot_api_key) + + update_data = { + riot_puuid: summoner_data['puuid'], + riot_summoner_id: summoner_data['id'], + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + sync_status: 'success', + last_sync_at: Time.current + } + + solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } + if solo_queue + update_data.merge!({ + solo_queue_tier: solo_queue['tier'], + solo_queue_rank: solo_queue['rank'], + solo_queue_lp: solo_queue['leaguePoints'], + solo_queue_wins: solo_queue['wins'], + solo_queue_losses: solo_queue['losses'] + }) + end + + flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } + if flex_queue + update_data.merge!({ + flex_queue_tier: flex_queue['tier'], + flex_queue_rank: flex_queue['rank'], + flex_queue_lp: flex_queue['leaguePoints'] + }) + end + + player.update!(update_data) + + Rails.logger.info "Successfully synced player #{player_id} from Riot API" + + rescue StandardError => e + Rails.logger.error "Failed to sync player #{player_id}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + player.update(sync_status: 'error', last_sync_at: Time.current) + end + end + + private + + def fetch_summoner_by_name(summoner_name, region, api_key) + require 'net/http' + require 'json' + + game_name, tag_line = summoner_name.split('#') + tag_line ||= region.upcase + + account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{URI.encode_www_form_component(game_name)}/#{URI.encode_www_form_component(tag_line)}" + account_uri = URI(account_url) + account_request = Net::HTTP::Get.new(account_uri) + account_request['X-Riot-Token'] = api_key + + account_response = Net::HTTP.start(account_uri.hostname, account_uri.port, use_ssl: true) do |http| + http.request(account_request) + end + + unless account_response.is_a?(Net::HTTPSuccess) + raise "Riot API Error: #{account_response.code} - #{account_response.body}" + end + + account_data = JSON.parse(account_response.body) + puuid = account_data['puuid'] + + fetch_summoner_by_puuid(puuid, region, api_key) + end + + def fetch_summoner_by_puuid(puuid, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise "Riot API Error: #{response.code} - #{response.body}" + end + + JSON.parse(response.body) + end + + def fetch_ranked_stats(summoner_id, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-summoner/#{summoner_id}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise "Riot API Error: #{response.code} - #{response.body}" + end + + JSON.parse(response.body) + end +end diff --git a/app/modules/players/jobs/sync_player_job.rb b/app/modules/players/jobs/sync_player_job.rb new file mode 100644 index 0000000..e983828 --- /dev/null +++ b/app/modules/players/jobs/sync_player_job.rb @@ -0,0 +1,142 @@ +class SyncPlayerJob < ApplicationJob + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(player_id, region = 'BR') + player = Player.find(player_id) + riot_service = RiotApiService.new + + if player.riot_puuid.blank? + sync_summoner_by_name(player, riot_service, region) + else + sync_summoner_by_puuid(player, riot_service, region) + end + + sync_rank_info(player, riot_service, region) if player.riot_summoner_id.present? + + sync_champion_mastery(player, riot_service, region) if player.riot_puuid.present? + + player.update!(last_sync_at: Time.current) + + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Player not found in Riot API: #{player.summoner_name} - #{e.message}") + rescue RiotApiService::UnauthorizedError => e + Rails.logger.error("Riot API authentication failed: #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync player #{player.id}: #{e.message}") + raise + end + + private + + def sync_summoner_by_name(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_name( + summoner_name: player.summoner_name, + region: region + ) + + player.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + def sync_summoner_by_puuid(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_puuid( + puuid: player.riot_puuid, + region: region + ) + + if player.summoner_name != summoner_data[:summoner_name] + player.update!(summoner_name: summoner_data[:summoner_name]) + end + end + + def sync_rank_info(player, riot_service, region) + league_data = riot_service.get_league_entries( + summoner_id: player.riot_summoner_id, + region: region + ) + + update_attributes = {} + + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + solo_queue_tier: solo[:tier], + solo_queue_rank: solo[:rank], + solo_queue_lp: solo[:lp], + solo_queue_wins: solo[:wins], + solo_queue_losses: solo[:losses] + ) + + if should_update_peak?(player, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank], + peak_season: current_season + ) + end + end + + if league_data[:flex_queue].present? + flex = league_data[:flex_queue] + update_attributes.merge!( + flex_queue_tier: flex[:tier], + flex_queue_rank: flex[:rank], + flex_queue_lp: flex[:lp] + ) + end + + player.update!(update_attributes) if update_attributes.present? + end + + def sync_champion_mastery(player, riot_service, region) + mastery_data = riot_service.get_champion_mastery( + puuid: player.riot_puuid, + region: region + ) + + champion_id_map = load_champion_id_map + + mastery_data.take(20).each do |mastery| + champion_name = champion_id_map[mastery[:champion_id]] + next unless champion_name + + champion_pool = player.champion_pools.find_or_initialize_by(champion: champion_name) + champion_pool.update!( + mastery_level: mastery[:champion_level], + mastery_points: mastery[:champion_points], + last_played_at: mastery[:last_played] + ) + end + end + + def should_update_peak?(player, new_tier, new_rank) + return true if player.peak_tier.blank? + + tier_values = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] + rank_values = %w[IV III II I] + + current_tier_index = tier_values.index(player.peak_tier&.upcase) || 0 + new_tier_index = tier_values.index(new_tier&.upcase) || 0 + + return true if new_tier_index > current_tier_index + return false if new_tier_index < current_tier_index + + current_rank_index = rank_values.index(player.peak_rank&.upcase) || 0 + new_rank_index = rank_values.index(new_rank&.upcase) || 0 + + new_rank_index > current_rank_index + end + + def current_season + Time.current.year - 2025 # Season 1 was 2011 + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end +end diff --git a/app/modules/players/serializers/champion_pool_serializer.rb b/app/modules/players/serializers/champion_pool_serializer.rb new file mode 100644 index 0000000..87f27be --- /dev/null +++ b/app/modules/players/serializers/champion_pool_serializer.rb @@ -0,0 +1,14 @@ +class ChampionPoolSerializer < Blueprinter::Base + identifier :id + + fields :champion, :games_played, :wins, :losses, + :average_kda, :average_cs, :mastery_level, :mastery_points, + :last_played_at, :created_at, :updated_at + + field :win_rate do |pool| + return 0 if pool.games_played.to_i.zero? + ((pool.wins.to_f / pool.games_played) * 100).round(1) + end + + association :player, blueprint: PlayerSerializer +end diff --git a/app/modules/players/serializers/player_serializer.rb b/app/modules/players/serializers/player_serializer.rb new file mode 100644 index 0000000..43a1081 --- /dev/null +++ b/app/modules/players/serializers/player_serializer.rb @@ -0,0 +1,48 @@ +class PlayerSerializer < Blueprinter::Base + identifier :id + + fields :summoner_name, :real_name, :role, :status, + :jersey_number, :birth_date, :country, + :contract_start_date, :contract_end_date, + :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, + :solo_queue_wins, :solo_queue_losses, + :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, + :peak_tier, :peak_rank, :peak_season, + :riot_puuid, :riot_summoner_id, + :twitter_handle, :twitch_channel, :instagram_handle, + :notes, :sync_status, :last_sync_at, :created_at, :updated_at + + field :age do |player| + player.age + end + + field :win_rate do |player| + player.win_rate + end + + field :current_rank do |player| + player.current_rank_display + end + + field :peak_rank do |player| + player.peak_rank_display + end + + field :contract_status do |player| + player.contract_status + end + + field :main_champions do |player| + player.main_champions + end + + field :social_links do |player| + player.social_links + end + + field :needs_sync do |player| + player.needs_sync? + end + + association :organization, blueprint: OrganizationSerializer +end diff --git a/app/modules/players/services/riot_sync_service.rb b/app/modules/players/services/riot_sync_service.rb new file mode 100644 index 0000000..86e89e5 --- /dev/null +++ b/app/modules/players/services/riot_sync_service.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +module Players + module Services + # Service responsible for syncing player data from Riot API + # Extracted from PlayersController to follow Single Responsibility Principle + class RiotSyncService + require 'net/http' + require 'json' + + attr_reader :player, :region, :api_key + + def initialize(player, region: nil, api_key: nil) + @player = player + @region = region || player.region.presence&.downcase || 'br1' + @api_key = api_key || ENV['RIOT_API_KEY'] + end + + def self.import(summoner_name:, role:, region:, organization:, api_key: nil) + new(nil, region: region, api_key: api_key) + .import_player(summoner_name, role, organization) + end + + def sync + validate_player! + validate_api_key! + + summoner_data = fetch_summoner_data + ranked_data = fetch_ranked_stats(summoner_data['puuid']) + + update_player_data(summoner_data, ranked_data) + + { success: true, player: player } + rescue StandardError => e + handle_sync_error(e) + end + + def import_player(summoner_name, role, organization) + validate_api_key! + + summoner_data, account_data = fetch_summoner_by_name(summoner_name) + ranked_data = fetch_ranked_stats(summoner_data['puuid']) + + player_data = build_player_data(summoner_data, ranked_data, account_data, role) + player = organization.players.create!(player_data) + + { success: true, player: player, summoner_name: "#{account_data['gameName']}##{account_data['tagLine']}" } + rescue ActiveRecord::RecordInvalid => e + { success: false, error: e.message, code: 'VALIDATION_ERROR' } + rescue StandardError => e + { success: false, error: e.message, code: 'RIOT_API_ERROR' } + end + + def self.search_riot_id(summoner_name, region: 'br1', api_key: nil) + service = new(nil, region: region, api_key: api_key || ENV['RIOT_API_KEY']) + service.search_player(summoner_name) + end + + def search_player(summoner_name) + validate_api_key! + + game_name, tag_line = parse_riot_id(summoner_name) + + if summoner_name.include?('#') || summoner_name.include?('-') + begin + account_data = fetch_account_by_riot_id(game_name, tag_line) + return { + success: true, + found: true, + game_name: account_data['gameName'], + tag_line: account_data['tagLine'], + puuid: account_data['puuid'], + riot_id: "#{account_data['gameName']}##{account_data['tagLine']}" + } + rescue StandardError => e + Rails.logger.info "Exact match failed: #{e.message}" + end + end + + tag_variations = build_tag_variations(tag_line) + result = try_tag_variations(game_name, tag_variations) + + if result + { + success: true, + found: true, + **result, + message: "Player found! Use this Riot ID: #{result[:riot_id]}" + } + else + { + success: false, + found: false, + error: "Player not found. Tried game name '#{game_name}' with tags: #{tag_variations.join(', ')}", + game_name: game_name, + tried_tags: tag_variations + } + end + rescue StandardError => e + { success: false, error: e.message, code: 'SEARCH_ERROR' } + end + + private + + def validate_player! + return if player.riot_puuid.present? || player.summoner_name.present? + + raise 'Player must have either Riot PUUID or summoner name to sync' + end + + def validate_api_key! + return if api_key.present? + + raise 'Riot API key not configured' + end + + def fetch_summoner_data + if player.riot_puuid.present? + fetch_summoner_by_puuid(player.riot_puuid) + else + fetch_summoner_by_name(player.summoner_name).first + end + end + + def fetch_summoner_by_name(summoner_name) + game_name, tag_line = parse_riot_id(summoner_name) + + tag_variations = build_tag_variations(tag_line) + + account_data = nil + tag_variations.each do |tag| + begin + Rails.logger.info "Trying Riot ID: #{game_name}##{tag}" + account_data = fetch_account_by_riot_id(game_name, tag) + Rails.logger.info "✅ Found player: #{game_name}##{tag}" + break + rescue StandardError => e + Rails.logger.debug "Tag '#{tag}' failed: #{e.message}" + next + end + end + + unless account_data + raise "Player not found. Tried: #{tag_variations.map { |t| "#{game_name}##{t}" }.join(', ')}" + end + + puuid = account_data['puuid'] + summoner_data = fetch_summoner_by_puuid(puuid) + + [summoner_data, account_data] + end + + def fetch_account_by_riot_id(game_name, tag_line) + url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{riot_url_encode(game_name)}/#{riot_url_encode(tag_line)}" + response = make_request(url) + + JSON.parse(response.body) + end + + def fetch_summoner_by_puuid(puuid) + url = "https://#{region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" + response = make_request(url) + + JSON.parse(response.body) + end + + def fetch_ranked_stats(puuid) + url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" + response = make_request(url) + + JSON.parse(response.body) + end + + def make_request(url) + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise "Riot API Error: #{response.code} - #{response.body}" + end + + response + end + + def update_player_data(summoner_data, ranked_data) + update_data = { + riot_puuid: summoner_data['puuid'], + riot_summoner_id: summoner_data['id'], + sync_status: 'success', + last_sync_at: Time.current + } + + update_data.merge!(extract_ranked_stats(ranked_data)) + + player.update!(update_data) + end + + def build_player_data(summoner_data, ranked_data, account_data, role) + player_data = { + summoner_name: "#{account_data['gameName']}##{account_data['tagLine']}", + role: role, + region: region, + status: 'active', + riot_puuid: summoner_data['puuid'], + riot_summoner_id: summoner_data['id'], + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + sync_status: 'success', + last_sync_at: Time.current + } + + player_data.merge!(extract_ranked_stats(ranked_data)) + end + + def extract_ranked_stats(ranked_data) + stats = {} + + solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } + if solo_queue + stats.merge!({ + solo_queue_tier: solo_queue['tier'], + solo_queue_rank: solo_queue['rank'], + solo_queue_lp: solo_queue['leaguePoints'], + solo_queue_wins: solo_queue['wins'], + solo_queue_losses: solo_queue['losses'] + }) + end + + flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } + if flex_queue + stats.merge!({ + flex_queue_tier: flex_queue['tier'], + flex_queue_rank: flex_queue['rank'], + flex_queue_lp: flex_queue['leaguePoints'] + }) + end + + stats + end + + def handle_sync_error(error) + Rails.logger.error "Riot API sync error: #{error.message}" + player&.update(sync_status: 'error', last_sync_at: Time.current) + + { success: false, error: error.message, code: 'RIOT_API_ERROR' } + end + + def parse_riot_id(summoner_name) + if summoner_name.include?('#') + game_name, tag_line = summoner_name.split('#', 2) + elsif summoner_name.include?('-') + parts = summoner_name.rpartition('-') + game_name = parts[0] + tag_line = parts[2] + else + game_name = summoner_name + tag_line = nil + end + + tag_line ||= region.upcase + tag_line = tag_line.strip.upcase if tag_line + + [game_name, tag_line] + end + + def build_tag_variations(tag_line) + [ + tag_line, # Original parsed tag + tag_line&.downcase, # lowercase + tag_line&.upcase, # UPPERCASE + tag_line&.capitalize, # Capitalized + region.upcase, # BR1 + region[0..1].upcase, # BR + 'BR1', 'BRSL', 'BR', 'br1', 'LAS', 'LAN' # Common tags + ].compact.uniq + end + + def try_tag_variations(game_name, tag_variations) + tag_variations.each do |tag| + begin + account_data = fetch_account_by_riot_id(game_name, tag) + return { + game_name: account_data['gameName'], + tag_line: account_data['tagLine'], + puuid: account_data['puuid'], + riot_id: "#{account_data['gameName']}##{account_data['tagLine']}" + } + rescue StandardError => e + Rails.logger.debug "Tag '#{tag}' not found: #{e.message}" + next + end + end + + nil + end + + def riot_url_encode(string) + URI.encode_www_form_component(string).gsub('+', '%20') + end + end + end +end diff --git a/app/modules/players/services/stats_service.rb b/app/modules/players/services/stats_service.rb new file mode 100644 index 0000000..a008917 --- /dev/null +++ b/app/modules/players/services/stats_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Players + module Services + # Service responsible for calculating player statistics and performance metrics + # Extracted from PlayersController to follow Single Responsibility Principle + class StatsService + attr_reader :player + + def initialize(player) + @player = player + end + + # Get comprehensive player statistics + def calculate_stats + matches = player.matches.order(game_start: :desc) + recent_matches = matches.limit(20) + player_stats = PlayerMatchStat.where(player: player, match: matches) + + { + player: player, + overall: calculate_overall_stats(matches, player_stats), + recent_form: calculate_recent_form_stats(recent_matches), + champion_pool: player.champion_pools.order(games_played: :desc).limit(5), + performance_by_role: calculate_performance_by_role(player_stats) + } + end + + # Calculate player win rate + def self.calculate_win_rate(matches) + return 0 if matches.empty? + + ((matches.victories.count.to_f / matches.count) * 100).round(1) + end + + # Calculate average KDA + def self.calculate_avg_kda(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + # Calculate recent form (W/L pattern) + def self.calculate_recent_form(matches) + matches.map { |m| m.victory? ? 'W' : 'L' } + end + + private + + def calculate_overall_stats(matches, player_stats) + { + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: self.class.calculate_win_rate(matches), + avg_kda: self.class.calculate_avg_kda(player_stats), + avg_cs: player_stats.average(:cs)&.round(1) || 0, + avg_vision_score: player_stats.average(:vision_score)&.round(1) || 0, + avg_damage: player_stats.average(:damage_dealt_champions)&.round(0) || 0 + } + end + + def calculate_recent_form_stats(recent_matches) + { + last_5_matches: self.class.calculate_recent_form(recent_matches.limit(5)), + last_10_matches: self.class.calculate_recent_form(recent_matches.limit(10)) + } + end + + def calculate_performance_by_role(stats) + stats.group(:role).select( + 'role', + 'COUNT(*) as games', + 'AVG(kills) as avg_kills', + 'AVG(deaths) as avg_deaths', + 'AVG(assists) as avg_assists', + 'AVG(performance_score) as avg_performance' + ).map do |stat| + { + role: stat.role, + games: stat.games, + avg_kda: { + kills: stat.avg_kills&.round(1) || 0, + deaths: stat.avg_deaths&.round(1) || 0, + assists: stat.avg_assists&.round(1) || 0 + }, + avg_performance: stat.avg_performance&.round(1) || 0 + } + end + end + end + end +end diff --git a/app/modules/riot_integration/controllers/riot_data_controller.rb b/app/modules/riot_integration/controllers/riot_data_controller.rb new file mode 100644 index 0000000..40b9020 --- /dev/null +++ b/app/modules/riot_integration/controllers/riot_data_controller.rb @@ -0,0 +1,144 @@ +module RiotIntegration + module Controllers + class RiotDataController < BaseController + skip_before_action :authenticate_request!, only: [:champions, :champion_details, :items, :version] + + # GET /api/v1/riot-data/champions + def champions + service = DataDragonService.new + champions = service.champion_id_map + + render_success({ + champions: champions, + count: champions.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champion data', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/champions/:champion_key + def champion_details + service = DataDragonService.new + champion = service.champion_by_key(params[:champion_key]) + + if champion.present? + render_success({ + champion: champion + }) + else + render_error('Champion not found', :not_found) + end + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champion details', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/all-champions + def all_champions + service = DataDragonService.new + champions = service.all_champions + + render_success({ + champions: champions, + count: champions.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champions', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/items + def items + service = DataDragonService.new + items = service.items + + render_success({ + items: items, + count: items.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch items', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/summoner-spells + def summoner_spells + service = DataDragonService.new + spells = service.summoner_spells + + render_success({ + summoner_spells: spells, + count: spells.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch summoner spells', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/version + def version + service = DataDragonService.new + version = service.latest_version + + render_success({ + version: version + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch version', :service_unavailable, details: e.message) + end + + # POST /api/v1/riot-data/clear-cache + def clear_cache + authorize :riot_data, :manage? + + service = DataDragonService.new + service.clear_cache! + + log_user_action( + action: 'clear_cache', + entity_type: 'RiotData', + entity_id: nil, + details: { message: 'Data Dragon cache cleared' } + ) + + render_success({ + message: 'Cache cleared successfully' + }) + end + + # POST /api/v1/riot-data/update-cache + def update_cache + authorize :riot_data, :manage? + + service = DataDragonService.new + service.clear_cache! + + # Preload all data + version = service.latest_version + champions = service.champion_id_map + items = service.items + spells = service.summoner_spells + + log_user_action( + action: 'update_cache', + entity_type: 'RiotData', + entity_id: nil, + details: { + version: version, + champions_count: champions.count, + items_count: items.count, + spells_count: spells.count + } + ) + + render_success({ + message: 'Cache updated successfully', + version: version, + data: { + champions: champions.count, + items: items.count, + summoner_spells: spells.count + } + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to update cache', :service_unavailable, details: e.message) + end + end + end +end diff --git a/app/modules/riot_integration/controllers/riot_integration_controller.rb b/app/modules/riot_integration/controllers/riot_integration_controller.rb new file mode 100644 index 0000000..eeeaeaf --- /dev/null +++ b/app/modules/riot_integration/controllers/riot_integration_controller.rb @@ -0,0 +1,41 @@ +class Api::V1::RiotIntegrationController < Api::V1::BaseController + def sync_status + players = organization_scoped(Player) + + total_players = players.count + synced_players = players.where(sync_status: 'success').count + pending_sync = players.where(sync_status: ['pending', nil]).or(players.where(sync_status: nil)).count + failed_sync = players.where(sync_status: 'error').count + + recently_synced = players.where('last_sync_at > ?', 24.hours.ago).count + + needs_sync = players.where(last_sync_at: nil) + .or(players.where('last_sync_at < ?', 1.hour.ago)) + .count + + recent_syncs = players + .where.not(last_sync_at: nil) + .order(last_sync_at: :desc) + .limit(10) + .map do |player| + { + id: player.id, + summoner_name: player.summoner_name, + last_sync_at: player.last_sync_at, + sync_status: player.sync_status || 'pending' + } + end + + render_success({ + stats: { + total_players: total_players, + synced_players: synced_players, + pending_sync: pending_sync, + failed_sync: failed_sync, + recently_synced: recently_synced, + needs_sync: needs_sync + }, + recent_syncs: recent_syncs + }) + end +end diff --git a/app/modules/riot_integration/services/data_dragon_service.rb b/app/modules/riot_integration/services/data_dragon_service.rb new file mode 100644 index 0000000..9093dc5 --- /dev/null +++ b/app/modules/riot_integration/services/data_dragon_service.rb @@ -0,0 +1,176 @@ +class DataDragonService + BASE_URL = 'https://ddragon.leagueoflegends.com'.freeze + + class DataDragonError < StandardError; end + + def initialize + @latest_version = nil + end + + def latest_version + @latest_version ||= fetch_latest_version + end + + def champion_id_map + Rails.cache.fetch('riot:champion_id_map', expires_in: 1.week) do + fetch_champion_data + end + end + + def champion_name_map + Rails.cache.fetch('riot:champion_name_map', expires_in: 1.week) do + champion_id_map.invert + end + end + + def all_champions + Rails.cache.fetch('riot:all_champions', expires_in: 1.week) do + fetch_all_champions_data + end + end + + def champion_by_key(champion_key) + all_champions[champion_key] + end + + def profile_icons + Rails.cache.fetch('riot:profile_icons', expires_in: 1.week) do + fetch_profile_icons + end + end + + def summoner_spells + Rails.cache.fetch('riot:summoner_spells', expires_in: 1.week) do + fetch_summoner_spells + end + end + + def items + Rails.cache.fetch('riot:items', expires_in: 1.week) do + fetch_items + end + end + + def clear_cache! + Rails.cache.delete('riot:champion_id_map') + Rails.cache.delete('riot:champion_name_map') + Rails.cache.delete('riot:all_champions') + Rails.cache.delete('riot:profile_icons') + Rails.cache.delete('riot:summoner_spells') + Rails.cache.delete('riot:items') + Rails.cache.delete('riot:latest_version') + @latest_version = nil + end + + private + + def fetch_latest_version + cached_version = Rails.cache.read('riot:latest_version') + return cached_version if cached_version.present? + + url = "#{BASE_URL}/api/versions.json" + response = make_request(url) + versions = JSON.parse(response.body) + + latest = versions.first + Rails.cache.write('riot:latest_version', latest, expires_in: 1.day) + latest + rescue StandardError => e + Rails.logger.error("Failed to fetch latest version: #{e.message}") + # Fallback to a recent known version + '14.1.1' + end + + def fetch_champion_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + champion_map = {} + data['data'].each do |_key, champion| + champion_id = champion['key'].to_i + champion_name = champion['id'] # This is the champion name like "Aatrox" + champion_map[champion_id] = champion_name + end + + champion_map + rescue StandardError => e + Rails.logger.error("Failed to fetch champion data: #{e.message}") + {} + end + + def fetch_all_champions_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch all champions data: #{e.message}") + {} + end + + def fetch_profile_icons + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/profileicon.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch profile icons: #{e.message}") + {} + end + + def fetch_summoner_spells + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/summoner.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch summoner spells: #{e.message}") + {} + end + + def fetch_items + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/item.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch items: #{e.message}") + {} + end + + def make_request(url) + conn = Faraday.new do |f| + f.request :retry, max: 3, interval: 0.5, backoff_factor: 2 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.options.timeout = 10 + end + + unless response.success? + raise DataDragonError, "Request failed with status #{response.status}" + end + + response + rescue Faraday::TimeoutError => e + raise DataDragonError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise DataDragonError, "Network error: #{e.message}" + end +end diff --git a/app/modules/riot_integration/services/riot_api_service.rb b/app/modules/riot_integration/services/riot_api_service.rb new file mode 100644 index 0000000..d9d5f09 --- /dev/null +++ b/app/modules/riot_integration/services/riot_api_service.rb @@ -0,0 +1,235 @@ +class RiotApiService + RATE_LIMITS = { + per_second: 20, + per_two_minutes: 100 + }.freeze + + REGIONS = { + 'BR' => { platform: 'BR1', region: 'americas' }, + 'NA' => { platform: 'NA1', region: 'americas' }, + 'EUW' => { platform: 'EUW1', region: 'europe' }, + 'EUNE' => { platform: 'EUN1', region: 'europe' }, + 'KR' => { platform: 'KR', region: 'asia' }, + 'JP' => { platform: 'JP1', region: 'asia' }, + 'OCE' => { platform: 'OC1', region: 'sea' }, + 'LAN' => { platform: 'LA1', region: 'americas' }, + 'LAS' => { platform: 'LA2', region: 'americas' }, + 'RU' => { platform: 'RU', region: 'europe' }, + 'TR' => { platform: 'TR1', region: 'europe' } + }.freeze + + class RiotApiError < StandardError; end + class RateLimitError < RiotApiError; end + class NotFoundError < RiotApiError; end + class UnauthorizedError < RiotApiError; end + + def initialize(api_key: nil) + @api_key = api_key || ENV['RIOT_API_KEY'] + raise RiotApiError, 'Riot API key not configured' if @api_key.blank? + end + + def get_summoner_by_name(summoner_name:, region:) + platform = platform_for_region(region) + url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-name/#{ERB::Util.url_encode(summoner_name)}" + + response = make_request(url) + parse_summoner_response(response) + end + + def get_summoner_by_puuid(puuid:, region:) + platform = platform_for_region(region) + url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" + + response = make_request(url) + parse_summoner_response(response) + end + + def get_league_entries(summoner_id:, region:) + platform = platform_for_region(region) + url = "https://#{platform}.api.riotgames.com/lol/league/v4/entries/by-summoner/#{summoner_id}" + + response = make_request(url) + parse_league_entries(response) + end + + def get_match_history(puuid:, region:, count: 20, start: 0) + regional_route = regional_route_for_region(region) + url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/by-puuid/#{puuid}/ids?start=#{start}&count=#{count}" + + response = make_request(url) + JSON.parse(response.body) + end + + def get_match_details(match_id:, region:) + regional_route = regional_route_for_region(region) + url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/#{match_id}" + + response = make_request(url) + parse_match_details(response) + end + + def get_champion_mastery(puuid:, region:) + platform = platform_for_region(region) + url = "https://#{platform}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/#{puuid}" + + response = make_request(url) + parse_champion_mastery(response) + end + + private + + def make_request(url) + check_rate_limit! + + conn = Faraday.new do |f| + f.request :retry, max: 3, interval: 0.5, backoff_factor: 2 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.headers['X-Riot-Token'] = @api_key + req.options.timeout = 10 + end + + handle_response(response) + rescue Faraday::TimeoutError => e + raise RiotApiError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise RiotApiError, "Network error: #{e.message}" + end + + def handle_response(response) + case response.status + when 200 + response + when 404 + raise NotFoundError, 'Resource not found' + when 401, 403 + raise UnauthorizedError, 'Invalid API key or unauthorized' + when 429 + retry_after = response.headers['Retry-After']&.to_i || 120 + raise RateLimitError, "Rate limit exceeded. Retry after #{retry_after} seconds" + when 500..599 + raise RiotApiError, "Riot API server error: #{response.status}" + else + raise RiotApiError, "Unexpected response: #{response.status}" + end + end + + def check_rate_limit! + return unless Rails.cache + + current_second = Time.current.to_i + key_second = "riot_api:rate_limit:second:#{current_second}" + key_two_min = "riot_api:rate_limit:two_minutes:#{current_second / 120}" + + count_second = Rails.cache.increment(key_second, 1, expires_in: 1.second) || 0 + count_two_min = Rails.cache.increment(key_two_min, 1, expires_in: 2.minutes) || 0 + + if count_second > RATE_LIMITS[:per_second] + sleep(1 - (Time.current.to_f % 1)) # Sleep until next second + end + + if count_two_min > RATE_LIMITS[:per_two_minutes] + raise RateLimitError, 'Rate limit exceeded for 2-minute window' + end + end + + def platform_for_region(region) + REGIONS.dig(region.upcase, :platform) || raise(RiotApiError, "Unknown region: #{region}") + end + + def regional_route_for_region(region) + REGIONS.dig(region.upcase, :region) || raise(RiotApiError, "Unknown region: #{region}") + end + + def parse_summoner_response(response) + data = JSON.parse(response.body) + { + summoner_id: data['id'], + puuid: data['puuid'], + summoner_name: data['name'], + summoner_level: data['summonerLevel'], + profile_icon_id: data['profileIconId'] + } + end + + def parse_league_entries(response) + entries = JSON.parse(response.body) + + { + solo_queue: find_queue_entry(entries, 'RANKED_SOLO_5x5'), + flex_queue: find_queue_entry(entries, 'RANKED_FLEX_SR') + } + end + + def find_queue_entry(entries, queue_type) + entry = entries.find { |e| e['queueType'] == queue_type } + return nil unless entry + + { + tier: entry['tier'], + rank: entry['rank'], + lp: entry['leaguePoints'], + wins: entry['wins'], + losses: entry['losses'] + } + end + + def parse_match_details(response) + data = JSON.parse(response.body) + info = data['info'] + metadata = data['metadata'] + + { + match_id: metadata['matchId'], + game_creation: Time.at(info['gameCreation'] / 1000), + game_duration: info['gameDuration'], + game_mode: info['gameMode'], + game_version: info['gameVersion'], + participants: info['participants'].map { |p| parse_participant(p) } + } + end + + def parse_participant(participant) + { + puuid: participant['puuid'], + summoner_name: participant['summonerName'], + champion_name: participant['championName'], + champion_id: participant['championId'], + team_id: participant['teamId'], + role: participant['teamPosition']&.downcase, + kills: participant['kills'], + deaths: participant['deaths'], + assists: participant['assists'], + gold_earned: participant['goldEarned'], + total_damage_dealt: participant['totalDamageDealtToChampions'], + total_damage_taken: participant['totalDamageTaken'], + minions_killed: participant['totalMinionsKilled'], + neutral_minions_killed: participant['neutralMinionsKilled'], + vision_score: participant['visionScore'], + wards_placed: participant['wardsPlaced'], + wards_killed: participant['wardsKilled'], + champion_level: participant['champLevel'], + first_blood_kill: participant['firstBloodKill'], + double_kills: participant['doubleKills'], + triple_kills: participant['tripleKills'], + quadra_kills: participant['quadraKills'], + penta_kills: participant['pentaKills'], + win: participant['win'] + } + end + + def parse_champion_mastery(response) + masteries = JSON.parse(response.body) + + masteries.map do |mastery| + { + champion_id: mastery['championId'], + champion_level: mastery['championLevel'], + champion_points: mastery['championPoints'], + last_played: Time.at(mastery['lastPlayTime'] / 1000) + } + end + end +end diff --git a/app/modules/schedules/controllers/schedules_controller.rb b/app/modules/schedules/controllers/schedules_controller.rb new file mode 100644 index 0000000..bd6bf84 --- /dev/null +++ b/app/modules/schedules/controllers/schedules_controller.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Schedules + module Controllers + class SchedulesController < Api::V1::BaseController + before_action :set_schedule, only: [:show, :update, :destroy] + + def index + schedules = organization_scoped(Schedule).includes(:match) + + schedules = schedules.where(event_type: params[:event_type]) if params[:event_type].present? + schedules = schedules.where(status: params[:status]) if params[:status].present? + + if params[:start_date].present? && params[:end_date].present? + schedules = schedules.where(start_time: params[:start_date]..params[:end_date]) + elsif params[:upcoming] == 'true' + schedules = schedules.where('start_time >= ?', Time.current) + elsif params[:past] == 'true' + schedules = schedules.where('end_time < ?', Time.current) + end + + if params[:today] == 'true' + schedules = schedules.where(start_time: Time.current.beginning_of_day..Time.current.end_of_day) + end + + if params[:this_week] == 'true' + schedules = schedules.where(start_time: Time.current.beginning_of_week..Time.current.end_of_week) + end + + sort_order = params[:sort_order] || 'asc' + schedules = schedules.order("start_time #{sort_order}") + + result = paginate(schedules) + + render_success({ + schedules: ScheduleSerializer.render_as_hash(result[:data]), + pagination: result[:pagination] + }) + end + + def show + render_success({ + schedule: ScheduleSerializer.render_as_hash(@schedule) + }) + end + + def create + schedule = organization_scoped(Schedule).new(schedule_params) + schedule.organization = current_organization + + if schedule.save + log_user_action( + action: 'create', + entity_type: 'Schedule', + entity_id: schedule.id, + new_values: schedule.attributes + ) + + render_created({ + schedule: ScheduleSerializer.render_as_hash(schedule) + }, message: 'Event scheduled successfully') + else + render_error( + message: 'Failed to create schedule', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: schedule.errors.as_json + ) + end + end + + def update + old_values = @schedule.attributes.dup + + if @schedule.update(schedule_params) + log_user_action( + action: 'update', + entity_type: 'Schedule', + entity_id: @schedule.id, + old_values: old_values, + new_values: @schedule.attributes + ) + + render_updated({ + schedule: ScheduleSerializer.render_as_hash(@schedule) + }) + else + render_error( + message: 'Failed to update schedule', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @schedule.errors.as_json + ) + end + end + + def destroy + if @schedule.destroy + log_user_action( + action: 'delete', + entity_type: 'Schedule', + entity_id: @schedule.id, + old_values: @schedule.attributes + ) + + render_deleted(message: 'Event deleted successfully') + else + render_error( + message: 'Failed to delete schedule', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_schedule + @schedule = organization_scoped(Schedule).find(params[:id]) + end + + def schedule_params + params.require(:schedule).permit( + :event_type, :title, :description, + :start_time, :end_time, :location, + :opponent_name, :status, :match_id, + :meeting_url, :all_day, :timezone, + :color, :is_recurring, :recurrence_rule, + :recurrence_end_date, :reminder_minutes, + required_players: [], optional_players: [], tags: [] + ) + end + end + end +end diff --git a/app/modules/schedules/serializers/schedule_serializer.rb b/app/modules/schedules/serializers/schedule_serializer.rb new file mode 100644 index 0000000..3f932b1 --- /dev/null +++ b/app/modules/schedules/serializers/schedule_serializer.rb @@ -0,0 +1,19 @@ +class ScheduleSerializer < Blueprinter::Base + identifier :id + + fields :event_type, :title, :description, :start_time, :end_time, + :location, :opponent_name, :status, + :meeting_url, :timezone, :all_day, + :tags, :color, :is_recurring, :recurrence_rule, + :recurrence_end_date, :reminder_minutes, + :required_players, :optional_players, :metadata, + :created_at, :updated_at + + field :duration_hours do |schedule| + return nil unless schedule.start_time && schedule.end_time + ((schedule.end_time - schedule.start_time) / 3600).round(1) + end + + association :organization, blueprint: OrganizationSerializer + association :match, blueprint: MatchSerializer +end diff --git a/app/modules/scouting/controllers/players_controller.rb b/app/modules/scouting/controllers/players_controller.rb new file mode 100644 index 0000000..5116866 --- /dev/null +++ b/app/modules/scouting/controllers/players_controller.rb @@ -0,0 +1,158 @@ +class Api::V1::Scouting::PlayersController < Api::V1::BaseController + before_action :set_scouting_target, only: [:show, :update, :destroy, :sync] + + def index + targets = organization_scoped(ScoutingTarget).includes(:added_by, :assigned_to) + + targets = targets.by_role(params[:role]) if params[:role].present? + targets = targets.by_status(params[:status]) if params[:status].present? + targets = targets.by_priority(params[:priority]) if params[:priority].present? + targets = targets.by_region(params[:region]) if params[:region].present? + + if params[:age_range].present? && params[:age_range].is_a?(Array) + min_age, max_age = params[:age_range] + targets = targets.where(age: min_age..max_age) if min_age && max_age + end + + targets = targets.active if params[:active] == 'true' + targets = targets.high_priority if params[:high_priority] == 'true' + targets = targets.needs_review if params[:needs_review] == 'true' + targets = targets.assigned_to_user(params[:assigned_to_id]) if params[:assigned_to_id].present? + + if params[:search].present? + search_term = "%#{params[:search]}%" + targets = targets.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) + end + + sort_by = params[:sort_by] || 'created_at' + sort_order = params[:sort_order] || 'desc' + + # Map 'rank' to actual column names + if sort_by == 'rank' + targets = targets.order("current_lp #{sort_order} NULLS LAST") + elsif sort_by == 'winrate' + targets = targets.order("performance_trend #{sort_order} NULLS LAST") + else + targets = targets.order("#{sort_by} #{sort_order}") + end + + result = paginate(targets) + + render_success({ + players: ScoutingTargetSerializer.render_as_hash(result[:data]), + total: result[:pagination][:total_count], + page: result[:pagination][:current_page], + per_page: result[:pagination][:per_page], + total_pages: result[:pagination][:total_pages] + }) + end + + def show + render_success({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + end + + def create + target = organization_scoped(ScoutingTarget).new(scouting_target_params) + target.organization = current_organization + target.added_by = current_user + + if target.save + log_user_action( + action: 'create', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: target.attributes + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Scouting target added successfully') + else + render_error( + message: 'Failed to add scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: target.errors.as_json + ) + end + end + + def update + old_values = @target.attributes.dup + + if @target.update(scouting_target_params) + log_user_action( + action: 'update', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: old_values, + new_values: @target.attributes + ) + + render_updated({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + else + render_error( + message: 'Failed to update scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @target.errors.as_json + ) + end + end + + def destroy + if @target.destroy + log_user_action( + action: 'delete', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: @target.attributes + ) + + render_deleted(message: 'Scouting target removed successfully') + else + render_error( + message: 'Failed to remove scouting target', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + def sync + # This will sync the scouting target with Riot API + # Will be implemented when Riot API service is ready + render_error( + message: 'Sync functionality not yet implemented', + code: 'NOT_IMPLEMENTED', + status: :not_implemented + ) + end + + private + + def set_scouting_target + @target = organization_scoped(ScoutingTarget).find(params[:id]) + end + + def scouting_target_params + # :role refers to in-game position (top/jungle/mid/adc/support), not user role + # nosemgrep + params.require(:scouting_target).permit( + :summoner_name, :real_name, :role, :region, :nationality, + :age, :status, :priority, :current_team, + :current_tier, :current_rank, :current_lp, + :peak_tier, :peak_rank, + :riot_puuid, :riot_summoner_id, + :email, :phone, :discord_username, :twitter_handle, + :scouting_notes, :contact_notes, + :availability, :salary_expectations, + :performance_trend, :assigned_to_id, + champion_pool: [] + ) + end +end diff --git a/app/modules/scouting/controllers/regions_controller.rb b/app/modules/scouting/controllers/regions_controller.rb new file mode 100644 index 0000000..d3c7cb3 --- /dev/null +++ b/app/modules/scouting/controllers/regions_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Scouting::RegionsController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: [:index] + + def index + regions = [ + { code: 'BR', name: 'Brazil', platform: 'BR1' }, + { code: 'NA', name: 'North America', platform: 'NA1' }, + { code: 'EUW', name: 'Europe West', platform: 'EUW1' }, + { code: 'EUNE', name: 'Europe Nordic & East', platform: 'EUN1' }, + { code: 'KR', name: 'Korea', platform: 'KR' }, + { code: 'JP', name: 'Japan', platform: 'JP1' }, + { code: 'OCE', name: 'Oceania', platform: 'OC1' }, + { code: 'LAN', name: 'Latin America North', platform: 'LA1' }, + { code: 'LAS', name: 'Latin America South', platform: 'LA2' }, + { code: 'RU', name: 'Russia', platform: 'RU' }, + { code: 'TR', name: 'Turkey', platform: 'TR1' } + ] + + render_success(regions) + end +end diff --git a/app/modules/scouting/controllers/watchlist_controller.rb b/app/modules/scouting/controllers/watchlist_controller.rb new file mode 100644 index 0000000..a728976 --- /dev/null +++ b/app/modules/scouting/controllers/watchlist_controller.rb @@ -0,0 +1,60 @@ +class Api::V1::Scouting::WatchlistController < Api::V1::BaseController + def index + # Watchlist is just high-priority scouting targets + targets = organization_scoped(ScoutingTarget) + .where(priority: %w[high critical]) + .where(status: %w[watching contacted negotiating]) + .includes(:added_by, :assigned_to) + .order(priority: :desc, created_at: :desc) + + render_success({ + watchlist: ScoutingTargetSerializer.render_as_hash(targets), + count: targets.size + }) + end + + def create + # Add a scouting target to watchlist by updating its priority + target = organization_scoped(ScoutingTarget).find(params[:scouting_target_id]) + + if target.update(priority: 'high') + log_user_action( + action: 'add_to_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'high' } + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Added to watchlist') + else + render_error( + message: 'Failed to add to watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end + + def destroy + target = organization_scoped(ScoutingTarget).find(params[:id]) + + if target.update(priority: 'medium') + log_user_action( + action: 'remove_from_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'medium' } + ) + + render_deleted(message: 'Removed from watchlist') + else + render_error( + message: 'Failed to remove from watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end +end diff --git a/app/modules/scouting/jobs/sync_scouting_target_job.rb b/app/modules/scouting/jobs/sync_scouting_target_job.rb new file mode 100644 index 0000000..2a531db --- /dev/null +++ b/app/modules/scouting/jobs/sync_scouting_target_job.rb @@ -0,0 +1,107 @@ +class SyncScoutingTargetJob < ApplicationJob + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(scouting_target_id) + target = ScoutingTarget.find(scouting_target_id) + riot_service = RiotApiService.new + + if target.riot_puuid.blank? + summoner_data = riot_service.get_summoner_by_name( + summoner_name: target.summoner_name, + region: target.region + ) + + target.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + if target.riot_summoner_id.present? + league_data = riot_service.get_league_entries( + summoner_id: target.riot_summoner_id, + region: target.region + ) + + update_rank_info(target, league_data) + end + + if target.riot_puuid.present? + mastery_data = riot_service.get_champion_mastery( + puuid: target.riot_puuid, + region: target.region + ) + + update_champion_pool(target, mastery_data) + end + + target.update!(last_sync_at: Time.current) + + Rails.logger.info("Successfully synced scouting target #{target.id}") + + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Scouting target not found in Riot API: #{target.summoner_name} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync scouting target #{target.id}: #{e.message}") + raise + end + + private + + def update_rank_info(target, league_data) + update_attributes = {} + + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + current_tier: solo[:tier], + current_rank: solo[:rank], + current_lp: solo[:lp] + ) + + # Update peak if current is higher + if should_update_peak?(target, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank] + ) + end + end + + target.update!(update_attributes) if update_attributes.present? + end + + def update_champion_pool(target, mastery_data) + champion_id_map = load_champion_id_map + champion_names = mastery_data.take(10).map do |mastery| + champion_id_map[mastery[:champion_id]] + end.compact + + target.update!(champion_pool: champion_names) + end + + def should_update_peak?(target, new_tier, new_rank) + return true if target.peak_tier.blank? + + tier_values = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] + rank_values = %w[IV III II I] + + current_tier_index = tier_values.index(target.peak_tier&.upcase) || 0 + new_tier_index = tier_values.index(new_tier&.upcase) || 0 + + return true if new_tier_index > current_tier_index + return false if new_tier_index < current_tier_index + + current_rank_index = rank_values.index(target.peak_rank&.upcase) || 0 + new_rank_index = rank_values.index(new_rank&.upcase) || 0 + + new_rank_index > current_rank_index + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end +end diff --git a/app/modules/scouting/serializers/scouting_target_serializer.rb b/app/modules/scouting/serializers/scouting_target_serializer.rb new file mode 100644 index 0000000..2ce9171 --- /dev/null +++ b/app/modules/scouting/serializers/scouting_target_serializer.rb @@ -0,0 +1,35 @@ +class ScoutingTargetSerializer < Blueprinter::Base + identifier :id + + fields :summoner_name, :role, :region, :status, :priority, :age + + fields :riot_puuid + + fields :current_tier, :current_rank, :current_lp + + fields :champion_pool, :playstyle, :strengths, :weaknesses + + fields :recent_performance, :performance_trend + + fields :email, :phone, :discord_username, :twitter_handle + + fields :notes, :metadata + + fields :last_reviewed, :created_at, :updated_at + + field :priority_text do |target| + target.priority&.titleize || 'Not Set' + end + + field :status_text do |target| + target.status&.titleize || 'Watching' + end + + field :current_rank_display do |target| + target.current_rank_display + end + + association :organization, blueprint: OrganizationSerializer + association :added_by, blueprint: UserSerializer + association :assigned_to, blueprint: UserSerializer +end diff --git a/app/modules/team_goals/controllers/team_goals_controller.rb b/app/modules/team_goals/controllers/team_goals_controller.rb new file mode 100644 index 0000000..30589e1 --- /dev/null +++ b/app/modules/team_goals/controllers/team_goals_controller.rb @@ -0,0 +1,134 @@ +class Api::V1::TeamGoalsController < Api::V1::BaseController + before_action :set_team_goal, only: [:show, :update, :destroy] + + def index + goals = organization_scoped(TeamGoal).includes(:player, :assigned_to, :created_by) + + goals = goals.by_status(params[:status]) if params[:status].present? + goals = goals.by_category(params[:category]) if params[:category].present? + goals = goals.for_player(params[:player_id]) if params[:player_id].present? + + goals = goals.team_goals if params[:type] == 'team' + goals = goals.player_goals if params[:type] == 'player' + goals = goals.active if params[:active] == 'true' + goals = goals.overdue if params[:overdue] == 'true' + goals = goals.expiring_soon(params[:expiring_days]&.to_i || 7) if params[:expiring_soon] == 'true' + + goals = goals.where(assigned_to_id: params[:assigned_to_id]) if params[:assigned_to_id].present? + + sort_by = params[:sort_by] || 'created_at' + sort_order = params[:sort_order] || 'desc' + goals = goals.order("#{sort_by} #{sort_order}") + + result = paginate(goals) + + render_success({ + goals: TeamGoalSerializer.render_as_hash(result[:data]), + pagination: result[:pagination], + summary: calculate_goals_summary(goals) + }) + end + + def show + render_success({ + goal: TeamGoalSerializer.render_as_hash(@goal) + }) + end + + def create + goal = organization_scoped(TeamGoal).new(team_goal_params) + goal.organization = current_organization + goal.created_by = current_user + + if goal.save + log_user_action( + action: 'create', + entity_type: 'TeamGoal', + entity_id: goal.id, + new_values: goal.attributes + ) + + render_created({ + goal: TeamGoalSerializer.render_as_hash(goal) + }, message: 'Goal created successfully') + else + render_error( + message: 'Failed to create goal', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: goal.errors.as_json + ) + end + end + + def update + old_values = @goal.attributes.dup + + if @goal.update(team_goal_params) + log_user_action( + action: 'update', + entity_type: 'TeamGoal', + entity_id: @goal.id, + old_values: old_values, + new_values: @goal.attributes + ) + + render_updated({ + goal: TeamGoalSerializer.render_as_hash(@goal) + }) + else + render_error( + message: 'Failed to update goal', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @goal.errors.as_json + ) + end + end + + def destroy + if @goal.destroy + log_user_action( + action: 'delete', + entity_type: 'TeamGoal', + entity_id: @goal.id, + old_values: @goal.attributes + ) + + render_deleted(message: 'Goal deleted successfully') + else + render_error( + message: 'Failed to delete goal', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_team_goal + @goal = organization_scoped(TeamGoal).find(params[:id]) + end + + def team_goal_params + params.require(:team_goal).permit( + :title, :description, :category, :metric_type, + :target_value, :current_value, :start_date, :end_date, + :status, :progress, :notes, + :player_id, :assigned_to_id + ) + end + + def calculate_goals_summary(goals) + { + total: goals.count, + by_status: goals.group(:status).count, + by_category: goals.group(:category).count, + active_count: goals.active.count, + completed_count: goals.where(status: 'completed').count, + overdue_count: goals.overdue.count, + avg_progress: goals.active.average(:progress)&.round(1) || 0 + } + end +end diff --git a/app/modules/team_goals/serializers/team_goal_serializer.rb b/app/modules/team_goals/serializers/team_goal_serializer.rb new file mode 100644 index 0000000..2635d11 --- /dev/null +++ b/app/modules/team_goals/serializers/team_goal_serializer.rb @@ -0,0 +1,44 @@ +class TeamGoalSerializer < Blueprinter::Base + identifier :id + + fields :title, :description, :category, :metric_type, + :target_value, :current_value, :start_date, :end_date, + :status, :progress, :created_at, :updated_at + + field :is_team_goal do |goal| + goal.is_team_goal? + end + + field :days_remaining do |goal| + goal.days_remaining + end + + field :days_total do |goal| + goal.days_total + end + + field :time_progress_percentage do |goal| + goal.time_progress_percentage + end + + field :is_overdue do |goal| + goal.is_overdue? + end + + field :target_display do |goal| + goal.target_display + end + + field :current_display do |goal| + goal.current_display + end + + field :completion_percentage do |goal| + goal.completion_percentage + end + + association :organization, blueprint: OrganizationSerializer + association :player, blueprint: PlayerSerializer + association :assigned_to, blueprint: UserSerializer + association :created_by, blueprint: UserSerializer +end diff --git a/app/modules/vod_reviews/controllers/vod_reviews_controller.rb b/app/modules/vod_reviews/controllers/vod_reviews_controller.rb new file mode 100644 index 0000000..a6b555d --- /dev/null +++ b/app/modules/vod_reviews/controllers/vod_reviews_controller.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module VodReviews + module Controllers + class VodReviewsController < Api::V1::BaseController + before_action :set_vod_review, only: [:show, :update, :destroy] + + def index + authorize VodReview + vod_reviews = organization_scoped(VodReview).includes(:match, :reviewer) + + vod_reviews = vod_reviews.where(status: params[:status]) if params[:status].present? + + vod_reviews = vod_reviews.where(match_id: params[:match_id]) if params[:match_id].present? + + vod_reviews = vod_reviews.where(reviewer_id: params[:reviewer_id]) if params[:reviewer_id].present? + + if params[:search].present? + search_term = "%#{params[:search]}%" + vod_reviews = vod_reviews.where('title ILIKE ?', search_term) + end + + sort_by = params[:sort_by] || 'created_at' + sort_order = params[:sort_order] || 'desc' + vod_reviews = vod_reviews.order("#{sort_by} #{sort_order}") + + result = paginate(vod_reviews) + + render_success({ + vod_reviews: VodReviewSerializer.render_as_hash(result[:data], include_timestamps_count: true), + pagination: result[:pagination] + }) + end + + def show + authorize @vod_review + vod_review_data = VodReviewSerializer.render_as_hash(@vod_review) + timestamps = VodTimestampSerializer.render_as_hash( + @vod_review.vod_timestamps.includes(:target_player, :created_by).order(:timestamp_seconds) + ) + + render_success({ + vod_review: vod_review_data, + timestamps: timestamps + }) + end + + def create + authorize VodReview + vod_review = organization_scoped(VodReview).new(vod_review_params) + vod_review.organization = current_organization + vod_review.reviewer = current_user + + if vod_review.save + log_user_action( + action: 'create', + entity_type: 'VodReview', + entity_id: vod_review.id, + new_values: vod_review.attributes + ) + + render_created({ + vod_review: VodReviewSerializer.render_as_hash(vod_review) + }, message: 'VOD review created successfully') + else + render_error( + message: 'Failed to create VOD review', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: vod_review.errors.as_json + ) + end + end + + def update + authorize @vod_review + old_values = @vod_review.attributes.dup + + if @vod_review.update(vod_review_params) + log_user_action( + action: 'update', + entity_type: 'VodReview', + entity_id: @vod_review.id, + old_values: old_values, + new_values: @vod_review.attributes + ) + + render_updated({ + vod_review: VodReviewSerializer.render_as_hash(@vod_review) + }) + else + render_error( + message: 'Failed to update VOD review', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @vod_review.errors.as_json + ) + end + end + + def destroy + authorize @vod_review + if @vod_review.destroy + log_user_action( + action: 'delete', + entity_type: 'VodReview', + entity_id: @vod_review.id, + old_values: @vod_review.attributes + ) + + render_deleted(message: 'VOD review deleted successfully') + else + render_error( + message: 'Failed to delete VOD review', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_vod_review + @vod_review = organization_scoped(VodReview).find(params[:id]) + end + + def vod_review_params + params.require(:vod_review).permit( + :title, :description, :review_type, :review_date, + :video_url, :thumbnail_url, :duration, + :status, :is_public, :match_id, + tags: [], shared_with_players: [] + ) + end + end + end +end diff --git a/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb b/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb new file mode 100644 index 0000000..769ee9b --- /dev/null +++ b/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb @@ -0,0 +1,110 @@ +class Api::V1::VodTimestampsController < Api::V1::BaseController + before_action :set_vod_review, only: [:index, :create] + before_action :set_vod_timestamp, only: [:update, :destroy] + + def index + authorize @vod_review, :show? + timestamps = @vod_review.vod_timestamps + .includes(:target_player, :created_by) + .order(:timestamp_seconds) + + timestamps = timestamps.where(category: params[:category]) if params[:category].present? + timestamps = timestamps.where(importance: params[:importance]) if params[:importance].present? + timestamps = timestamps.where(target_player_id: params[:player_id]) if params[:player_id].present? + + render_success({ + timestamps: VodTimestampSerializer.render_as_hash(timestamps) + }) + end + + def create + authorize @vod_review, :update? + timestamp = @vod_review.vod_timestamps.new(vod_timestamp_params) + timestamp.created_by = current_user + + if timestamp.save + log_user_action( + action: 'create', + entity_type: 'VodTimestamp', + entity_id: timestamp.id, + new_values: timestamp.attributes + ) + + render_created({ + timestamp: VodTimestampSerializer.render_as_hash(timestamp) + }, message: 'Timestamp added successfully') + else + render_error( + message: 'Failed to create timestamp', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: timestamp.errors.as_json + ) + end + end + + def update + authorize @timestamp.vod_review, :update? + old_values = @timestamp.attributes.dup + + if @timestamp.update(vod_timestamp_params) + log_user_action( + action: 'update', + entity_type: 'VodTimestamp', + entity_id: @timestamp.id, + old_values: old_values, + new_values: @timestamp.attributes + ) + + render_updated({ + timestamp: VodTimestampSerializer.render_as_hash(@timestamp) + }) + else + render_error( + message: 'Failed to update timestamp', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @timestamp.errors.as_json + ) + end + end + + def destroy + authorize @timestamp.vod_review, :update? + if @timestamp.destroy + log_user_action( + action: 'delete', + entity_type: 'VodTimestamp', + entity_id: @timestamp.id, + old_values: @timestamp.attributes + ) + + render_deleted(message: 'Timestamp deleted successfully') + else + render_error( + message: 'Failed to delete timestamp', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_vod_review + @vod_review = organization_scoped(VodReview).find(params[:vod_review_id]) + end + + def set_vod_timestamp + @timestamp = VodTimestamp.joins(:vod_review) + .where(vod_reviews: { organization: current_organization }) + .find(params[:id]) + end + + def vod_timestamp_params + params.require(:vod_timestamp).permit( + :timestamp_seconds, :category, :importance, + :title, :description, :target_type, :target_player_id + ) + end +end diff --git a/app/modules/vod_reviews/serializers/vod_review_serializer.rb b/app/modules/vod_reviews/serializers/vod_review_serializer.rb new file mode 100644 index 0000000..643b9fb --- /dev/null +++ b/app/modules/vod_reviews/serializers/vod_review_serializer.rb @@ -0,0 +1,17 @@ +class VodReviewSerializer < Blueprinter::Base + identifier :id + + fields :title, :description, :review_type, :review_date, + :video_url, :thumbnail_url, :duration, + :is_public, :share_link, :shared_with_players, + :status, :tags, :metadata, + :created_at, :updated_at + + field :timestamps_count do |vod_review, options| + options[:include_timestamps_count] ? vod_review.vod_timestamps.count : nil + end + + association :organization, blueprint: OrganizationSerializer + association :match, blueprint: MatchSerializer + association :reviewer, blueprint: UserSerializer +end diff --git a/app/modules/vod_reviews/serializers/vod_timestamp_serializer.rb b/app/modules/vod_reviews/serializers/vod_timestamp_serializer.rb new file mode 100644 index 0000000..388fe6b --- /dev/null +++ b/app/modules/vod_reviews/serializers/vod_timestamp_serializer.rb @@ -0,0 +1,23 @@ +class VodTimestampSerializer < Blueprinter::Base + identifier :id + + fields :timestamp_seconds, :category, :importance, :title, + :description, :created_at, :updated_at + + field :formatted_timestamp do |timestamp| + seconds = timestamp.timestamp_seconds + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + secs = seconds % 60 + + if hours > 0 + format('%02d:%02d:%02d', hours, minutes, secs) + else + format('%02d:%02d', minutes, secs) + end + end + + association :vod_review, blueprint: VodReviewSerializer + association :target_player, blueprint: PlayerSerializer + association :created_by, blueprint: UserSerializer +end diff --git a/db/schema.rb b/db/schema.rb index cc36ff3..9c88b78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_10_15_204948) do +ActiveRecord::Schema[7.2].define(version: 2025_10_16_000001) do create_schema "auth" create_schema "extensions" create_schema "graphql"