# frozen_string_literal: true class TournamentTransactionLogger class << self def lock_tournament!(tournament) raise ArgumentError, 'tournament is required' if tournament.nil? raise 'Tournament transaction log writes must happen inside a transaction' unless ActiveRecord::Base.connection.transaction_open? # Lock a fresh row instance so callers keep saved_change_to_* state for # after_commit callbacks on the object they just updated. Tournament.lock.find(tournament.id) end def record!(tournament:, user:, action:, message:, metadata: {}) retries = 0 begin Tournament.transaction(requires_new: true) do lock_tournament!(tournament) TournamentTransactionLogEntry.create!( tournament: tournament, user: user, sequence: next_sequence_for(tournament), action: action, message: message, metadata: metadata.deep_stringify_keys ) end rescue ActiveRecord::RecordNotUnique retries += 1 retry if retries < 3 raise end end def record_tournament_created!(tournament:, user:) record!( tournament: tournament, user: user, action: 'tournament.created', message: "#{user_label(user)} created tournament #{tournament.name}(#{tournament.id})", metadata: { tournament_id: tournament.id, tournament_name: tournament.name } ) end def record_timer_changed!(tournament:, user:, old_timer:, new_timer:) record!( tournament: tournament, user: user, action: 'tournament.timer_changed', message: "#{user_label(user)} changed tournament timer for #{tournament.name}(#{tournament.id})", metadata: { old_timer: old_timer, new_timer: new_timer } ) end def record_match_state_change!(match:, user:, old_state:, new_state:, details: {}) match.reload stage_type = match.group_match? ? 'groupstage' : 'playoffs' group_scores = details.fetch(:group_scores, []) advanced_match_ids = details.fetch(:advanced_match_ids, []) metadata = match_metadata(match).merge( old_state: old_state, new_state: new_state, stage_type: stage_type, winner_team_id: match.winner&.id, advanced_match_ids: advanced_match_ids, group_scores: group_scores_metadata(group_scores) ) record!( tournament: match.tournament, user: user, action: 'match.state_changed', message: match_state_message( match: match, user: user, new_state: new_state, details: details.merge(stage_type: stage_type) ), metadata: metadata ) end def record_match_score_update!(match_score:, user:, changes:, group_scores: []) return if changes.empty? match_score.reload match = match_score.match metadata = match_metadata(match).merge( match_score_id: match_score.id, team_id: match_score.team_id, changes: changes, group_scores: group_scores_metadata(group_scores) ) record!( tournament: match.tournament, user: user, action: 'match_score.updated', message: match_score_message( match_score: match_score, user: user, changes: changes, group_scores: group_scores ), metadata: metadata ) end def record_decider_match_created!(match:, user:, group_scores: []) record!( tournament: match.tournament, user: user, action: 'decider_match.created', message: "#{user_label(user)} added decider match #{match.id} (#{match_teams_label(match)})", metadata: match_metadata(match).merge(group_scores: group_scores_metadata(group_scores)) ) end def record_playoffs_start_failed!(stage:, user:, reason:, blocking_ties: []) record!( tournament: stage.tournament, user: user, action: 'playoffs.start_failed', message: playoffs_start_failed_message(user: user, reason: reason, blocking_ties: blocking_ties), metadata: { stage_id: stage.id, reason: reason, blocking_ties: blocking_ties } ) end def record_playoffs_started!(stage:, user:, advancing_teams:) record!( tournament: stage.tournament, user: user, action: 'playoffs.started', message: "#{user_label(user)} started playoffs, advancing teams: #{team_list_label(advancing_teams)}", metadata: { stage_id: stage.id, advancing_team_ids: advancing_teams.map(&:id), advancing_teams: teams_metadata(advancing_teams) } ) end def record_group_matches_started!(tournament:, user:, matches:) return if matches.empty? record!( tournament: tournament, user: user, action: 'group_matches.started', message: "#{user_label(user)} started group matches: #{matches.map(&:id).join(', ')}", metadata: { match_ids: matches.map(&:id), matches: matches.map { |match| match_metadata(match) } } ) end private def next_sequence_for(tournament) TournamentTransactionLogEntry.where(tournament_id: tournament.id).maximum(:sequence).to_i + 1 end def user_label(user) user&.username.presence || user&.email.presence || 'system' end def state_label(state) { 'in_progress' => 'running', 'not_started' => 'not started', 'not_ready' => 'not ready', 'single_team' => 'single team', 'undecided' => 'undecided', 'finished' => 'finished' }.fetch(state.to_s, state.to_s) end def match_state_message(match:, user:, new_state:, details:) group_scores = details.fetch(:group_scores, []) advanced_match_ids = details.fetch(:advanced_match_ids, []) stage_type = details.fetch(:stage_type) if new_state.to_s == 'finished' return finished_group_match_message(match, user, group_scores) if match.group_match? return finished_playoff_match_message(match, user, stage_type, advanced_match_ids) end message = "#{user_label(user)} changed match #{match.id} " \ "(#{match_teams_label(match)}) to #{state_label(new_state)}" message += ' - scores reset' if new_state.to_s == 'not_started' message end def finished_group_match_message(match, user, group_scores) "#{user_label(user)} changed match #{match.id} to finished (#{finish_result_label(match)}) - " \ "groupstage - group scores recalculated (#{group_scores_label(group_scores)})" end def finished_playoff_match_message(match, user, stage_type, advanced_match_ids) message = "#{user_label(user)} changed match #{match.id} to finished " \ "(#{finish_result_label(match)}) - #{stage_type}" return message if advanced_match_ids.empty? || match.winner.nil? "#{message} - #{team_label(match.winner)} advances to match #{advanced_match_ids.join(', ')}" end def match_score_message(match_score:, user:, changes:, group_scores:) match = match_score.match change_label = changes.map do |field, values| "#{field} from #{values.fetch(:from)} to #{values.fetch(:to)}" end.join(', ') message = "#{user_label(user)} changed matchscore #{match_score.id} from match #{match.id}: #{change_label} " \ "(#{score_detail_label(match_score, changes)})" return message unless match.group_match? "#{message} - group scores recalculated (#{group_scores_label(group_scores)})" end def playoffs_start_failed_message(user:, reason:, blocking_ties:) case reason.to_s when 'decider_missing' "#{user_label(user)} attempted playoff start, failed because decider missing " \ "(#{blocking_ties_label(blocking_ties)})" when 'unfinished_matches' "#{user_label(user)} attempted playoff start, failed because group stage has unfinished matches" else "#{user_label(user)} attempted playoff start, failed because #{reason}" end end def finish_result_label(match) winner = match.winner return "#{team_label(winner)} wins" unless winner.nil? teams = match.teams return "draw between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2 'no winner' end def score_detail_label(match_score, changes) team = match_score.team opponent = match_score.match.teams.find { |candidate| candidate.id != team.id } field = score_detail_field(changes) points = score_detail_points(match_score, changes, field) "#{points} #{field.to_s.tr('_', ' ')} for #{team_label(team)} in match vs #{team_label(opponent)}" end def score_detail_field(changes) return :hidden_points if changes.key?(:hidden_points) || changes.key?('hidden_points') :points end def score_detail_points(match_score, changes, field) changes.dig(field.to_s, :to) || changes.dig(field, :to) || match_score.public_send(field) end def match_teams_label(match) teams = match.teams return "between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2 return "with #{team_label(teams.first)}" if teams.size == 1 'without assigned teams' end def team_label(team) return 'unknown team' if team.nil? "#{team.name}(#{team.id})" end def team_list_label(teams) teams.map { |team| team_label(team) }.join(', ') end def blocking_ties_label(blocking_ties) blocking_ties.map do |tie| names = tie.fetch(:team_names, []) ids = tie.fetch(:team_ids, []) names.zip(ids).map { |name, id| "#{name}(#{id})" }.join(' vs ') end.join(', ') end def group_scores_label(group_scores) group_scores_metadata(group_scores).map do |score| "#{score.fetch(:team_name)}(#{score.fetch(:team_id)}) groupscore " \ "#{score.fetch(:group_points)}-#{score.fetch(:scored_points)}-#{score.fetch(:received_points)} " \ "pos #{score.fetch(:position)}" end.join(', ') end def group_scores_metadata(group_scores) Array(group_scores).map do |score| score.reload if score.persisted? { id: score.id, team_id: score.team_id, team_name: score.team.name, group_points: score.group_points, scored_points: score.scored_points, received_points: score.received_points, position: score.position } end end def match_metadata(match) { match_id: match.id, decider: match.decider?, group_id: match.group_id, stage_id: match.stage_id, team_ids: match.teams.map(&:id), teams: teams_metadata(match.teams) } end def teams_metadata(teams) teams.map do |team| { id: team.id, name: team.name } end end end end