diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index 502da1f..7d3af67 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -13,14 +13,38 @@ class MatchScoresController < ApplicationController # PATCH/PUT /scores/1 def update - if @match_score.update(match_score_params) - UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? - push_sync_if_needed!(@match_score.tournament) - TournamentLiveBroadcast.call(@match_score.tournament.id) - render json: @match_score - else - render json: @match_score.errors, status: :unprocessable_content + synced_tournament = nil + + MatchScore.transaction do + tournament = @match_score.tournament + TournamentTransactionLogger.lock_tournament!(tournament) + @match_score.reload + old_values = score_values(@match_score) + + if @match_score.update(match_score_params) + group_scores = [] + if @match_score.part_of_group_match? + UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) + group_scores = group_scores_for_log(@match_score.match.group) + end + TournamentTransactionLogger.record_match_score_update!( + match_score: @match_score, + user: current_user, + changes: score_changes(old_values, @match_score), + group_scores: group_scores + ) + synced_tournament = tournament + render json: @match_score + else + render json: @match_score.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + return if synced_tournament.nil? + + push_sync_if_needed!(synced_tournament) + TournamentLiveBroadcast.call(synced_tournament.id) end private @@ -35,6 +59,30 @@ class MatchScoresController < ApplicationController params.slice(:points, :hidden_points).permit! end + def score_values(match_score) + { + points: match_score.points, + hidden_points: match_score.hidden_points + } + end + + def score_changes(old_values, match_score) + permitted_params = match_score_params + score_values(match_score).each_with_object({}) do |(field, new_value), changes| + next unless permitted_params.key?(field) || permitted_params.key?(field.to_s) + next if old_values[field] == new_value + + changes[field] = { + from: old_values[field], + to: new_value + } + end + end + + def group_scores_for_log(group) + group.group_scores.includes(:team).reload + end + def push_sync_if_needed!(tournament) TournamentSyncEnqueue.call(tournament) end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 8a83897..42477ba 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -23,37 +23,41 @@ class MatchesController < ApplicationController # POST /groups/:group_id/matches def create - match = build_decider_match + match = nil + synced_tournament = nil - if match.errors.any? - render json: match.errors, status: :unprocessable_content - elsif match.save - UpdateGroupsGroupScoresAndSave.call(group: @group) - push_sync_if_needed!(@group.tournament) - TournamentLiveBroadcast.call(@group.tournament.id) - render json: match, include: %w[match_scores.team group], status: :created - else - render json: match.errors, status: :unprocessable_content + Match.transaction do + TournamentTransactionLogger.lock_tournament!(@group.tournament) + match = build_decider_match + + if match.errors.any? + render json: match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + elsif match.save + UpdateGroupsGroupScoresAndSave.call(group: @group) + group_scores = group_scores_for_log(@group) + TournamentTransactionLogger.record_decider_match_created!(match: match, user: current_user, group_scores: group_scores) + synced_tournament = @group.tournament + render json: match, include: %w[match_scores.team group], status: :created + else + render json: match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end # PATCH/PUT /matches/1 def update new_state = match_params['state'] synced_tournament = nil + @transaction_log_group_scores = [] + @transaction_log_advanced_match_ids = [] Match.transaction do - if @match.update(match_params) - handle_match_end if new_state == 'finished' - handle_match_stop if new_state == 'not_started' - update_group_scores if @match.group_match? && %w[in_progress not_started].include?(new_state) - - render json: @match - synced_tournament = @match.tournament - else - render json: @match.errors, status: :unprocessable_content - raise ActiveRecord::Rollback - end + synced_tournament = update_match_inside_transaction(new_state) end push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? @@ -92,17 +96,21 @@ class MatchesController < ApplicationController unless UpdateGroupsGroupScoresAndSave.call(group: group).success? logger.warn "Updating groups group score failed for #{group}" end + @transaction_log_group_scores = group_scores_for_log(group) + return end - return if @match.group_match? - if @match.winner.nil? render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' }, status: :unprocessable_content raise ActiveRecord::Rollback end - return if PopulateMatchBelowAndSave.call(match: @match).success? + result = PopulateMatchBelowAndSave.call(match: @match) + if result.success? + @transaction_log_advanced_match_ids = advanced_match_ids_for(result) + return + end render json: { error: 'Moving Team one stage down failed' }, status: :unprocessable_content @@ -117,27 +125,62 @@ class MatchesController < ApplicationController def update_group_scores group = @match.group - return if UpdateGroupsGroupScoresAndSave.call(group: group).success? + if UpdateGroupsGroupScoresAndSave.call(group: group).success? + @transaction_log_group_scores = group_scores_for_log(group) + return + end logger.warn "Updating groups group score failed for #{group}" end - def validate_params - case match_params['state'] - when 'in_progress' - render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_content \ - unless @match.not_started? - when 'finished' - render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_content \ - unless @match.in_progress? - when 'not_started' - render json: { error: 'Match can\'t stop in this state' }, status: :unprocessable_content \ - unless @match.in_progress? + def update_match_inside_transaction(new_state) + tournament = @match.tournament + TournamentTransactionLogger.lock_tournament!(tournament) + @match.reload + validate_transition_inside_transaction(new_state) + + old_state = @match.state + if @match.update(match_params) + record_successful_match_update(old_state, new_state) + render json: @match + tournament else - render json: { error: 'Invalid target state' }, status: :unprocessable_content + render json: @match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback end end + def validate_transition_inside_transaction(new_state) + transition_error = match_transition_error(@match, new_state) + return if transition_error.nil? + + render json: { error: transition_error }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + def record_successful_match_update(old_state, new_state) + handle_match_end if new_state == 'finished' + handle_match_stop if new_state == 'not_started' + update_group_scores if @match.group_match? && %w[in_progress not_started].include?(new_state) + TournamentTransactionLogger.record_match_state_change!( + match: @match, + user: current_user, + old_state: old_state, + new_state: new_state, + details: { + group_scores: @transaction_log_group_scores, + advanced_match_ids: @transaction_log_advanced_match_ids + } + ) + end + + def validate_params + transition_error = match_transition_error(@match, match_params['state']) + return if transition_error.nil? + + render json: { error: transition_error }, status: :unprocessable_content + end + def set_match @match = Match.find(params[:id]) end @@ -186,6 +229,27 @@ class MatchesController < ApplicationController end end + def match_transition_error(match, new_state) + case new_state + when 'in_progress' + 'Match can\'t start in this state' unless match.not_started? + when 'finished' + 'Match can\'t finish in this state' unless match.in_progress? + when 'not_started' + 'Match can\'t stop in this state' unless match.in_progress? + else + 'Invalid target state' + end + end + + def group_scores_for_log(group) + group.group_scores.includes(:team).reload + end + + def advanced_match_ids_for(result) + Array(result.object_to_save).flatten.grep(Match).reject { |match| match.id == @match.id }.map(&:id) + end + def push_sync_if_needed!(tournament) TournamentSyncEnqueue.call(tournament) end diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index 972b159..194bbc0 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -13,43 +13,81 @@ class StagesController < ApplicationController # PUT /stages/1 def update - if stage_params[:state] == 'finished' - unless @stage.state == 'in_progress' - render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content - return - end + return render_invalid_stage_state unless stage_params[:state] == 'finished' + return render_stage_not_running unless @stage.in_progress? - synced_tournament = nil - Stage.transaction do - if @stage.update(stage_params) - handle_group_stage_end - - render json: @stage - synced_tournament = @stage.tournament - else - render json: @stage.errors, status: :unprocessable_content - raise ActiveRecord::Rollback - end - end - push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? - TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? - else - render json: { - error: 'The state attribute may only be changed to finished' - }, status: :unprocessable_content - end + finish_group_stage end private - def handle_group_stage_end - unless @stage.over? - render json: { - error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs' - }, status: :unprocessable_content + def finish_group_stage + synced_tournament = nil + @playoff_start_failure = nil + @transaction_log_advancing_teams = [] + + Stage.transaction do + synced_tournament = finish_group_stage_inside_transaction + end + record_playoff_start_failure if @playoff_start_failure + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? + end + + def finish_group_stage_inside_transaction + TournamentTransactionLogger.lock_tournament!(@stage.tournament) + @stage.reload + return unless stage_running_for_playoff_start? + return unless group_stage_over_for_playoff_start? + return unless recalculate_group_scores_for_playoff_start + return unless no_blocking_ties_for_playoff_start? + + start_playoffs_inside_transaction + end + + def start_playoffs_inside_transaction + @transaction_log_advancing_teams = GroupStageService.get_advancing_teams(@stage) + if @stage.update(stage_params) && + AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: @transaction_log_advancing_teams).success? + record_playoffs_started + render json: @stage + return @stage.tournament + elsif @stage.errors.any? + render json: @stage.errors, status: :unprocessable_content raise ActiveRecord::Rollback end + @playoff_start_failure = { reason: 'generation_failed', blocking_ties: [] } + render json: { error: 'Generating group stage failed' }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + def render_invalid_stage_state + render json: { + error: 'The state attribute may only be changed to finished' + }, status: :unprocessable_content + end + + def render_stage_not_running + render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content + end + + def stage_running_for_playoff_start? + return true if @stage.in_progress? + + render_stage_not_running + false + end + + def group_stage_over_for_playoff_start? + return true if @stage.over? + + render_playoff_start_failure(reason: 'unfinished_matches') + false + end + + def recalculate_group_scores_for_playoff_start @stage.groups.each do |group| next if UpdateGroupsGroupScoresAndSave.call(group: group).success? @@ -57,20 +95,56 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end + true + end + + def no_blocking_ties_for_playoff_start? blocking_ties = GroupStageService.blocking_ties_for(@stage) - if blocking_ties.any? - render json: { - error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', - blocking_ties: blocking_ties - }, status: :unprocessable_content - raise ActiveRecord::Rollback + return true if blocking_ties.empty? + + render_playoff_start_failure(reason: 'decider_missing', blocking_ties: blocking_ties) + false + end + + def render_playoff_start_failure(reason:, blocking_ties: []) + TournamentTransactionLogger.record_playoffs_start_failed!( + stage: @stage, + user: current_user, + reason: reason, + blocking_ties: blocking_ties + ) + render json: playoff_start_failure_payload(reason, blocking_ties), status: :unprocessable_content + nil + end + + def playoff_start_failure_payload(reason, blocking_ties) + if reason == 'unfinished_matches' + return { + error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs' + } end - return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? + { + error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', + blocking_ties: blocking_ties + } + end - render json: { error: 'Generating group stage failed' }, status: :unprocessable_content - raise ActiveRecord::Rollback + def record_playoffs_started + TournamentTransactionLogger.record_playoffs_started!( + stage: @stage, + user: current_user, + advancing_teams: @transaction_log_advancing_teams + ) + end + + def record_playoff_start_failure + TournamentTransactionLogger.record_playoffs_start_failed!( + stage: @stage, + user: current_user, + reason: @playoff_start_failure.fetch(:reason), + blocking_ties: @playoff_start_failure.fetch(:blocking_ties) + ) end def set_stage diff --git a/app/controllers/tournament_transaction_log_entries_controller.rb b/app/controllers/tournament_transaction_log_entries_controller.rb new file mode 100644 index 0000000..d860396 --- /dev/null +++ b/app/controllers/tournament_transaction_log_entries_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class TournamentTransactionLogEntriesController < ApplicationController + before_action :set_tournament + before_action :authenticate_user! + before_action -> { require_owner! @tournament.owner } + + def index + entries = @tournament.transaction_log_entries.includes(:user).order(:sequence) + render json: entries, each_serializer: TournamentTransactionLogEntrySerializer + end + + private + + def set_tournament + @tournament = Tournament.find(params[:id]) + end +end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 6c08577..f878c77 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -65,6 +65,7 @@ class TournamentsController < ApplicationController if tournament.read_only_mode? if tournament.save + TournamentTransactionLogger.record_tournament_created!(tournament: tournament, user: current_user) render json: tournament, status: :created, location: tournament else render json: tournament.errors, status: :unprocessable_content @@ -95,6 +96,7 @@ class TournamentsController < ApplicationController end # return appropriate result if result.success? + TournamentTransactionLogger.record_tournament_created!(tournament: result.tournament, user: current_user) render json: result.tournament, status: :created, location: result.tournament else render json: { error: 'Tournament generation failed' }, status: :unprocessable_content @@ -135,12 +137,29 @@ class TournamentsController < ApplicationController # PATCH /tournaments/:id/timer def set_timer - if @tournament.update(timer_params) - push_sync_if_needed!(@tournament) - render json: @tournament - else - render json: @tournament.errors, status: :unprocessable_content + synced_tournament = nil + + Tournament.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + old_timer = serialize_timer_state(@tournament) + + if @tournament.update(timer_params) + TournamentTransactionLogger.record_timer_changed!( + tournament: @tournament, + user: current_user, + old_timer: old_timer, + new_timer: serialize_timer_state(@tournament) + ) + synced_tournament = @tournament + render json: @tournament + else + render json: @tournament.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? end def sync_state @@ -170,7 +189,7 @@ class TournamentsController < ApplicationController # PATCH /tournaments/:id/start_next_group_matches def start_next_group_matches - started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:) + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:, user: current_user) if started_matches.any? push_sync_if_needed!(@tournament) TournamentLiveBroadcast.call(@tournament.id) diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 0daf7ae..4063d27 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -13,6 +13,7 @@ class Tournament < ApplicationRecord has_many :stages, dependent: :destroy has_many :beamers, dependent: :destroy has_many :team_action_lists, dependent: :destroy + has_many :transaction_log_entries, class_name: 'TournamentTransactionLogEntry', dependent: :destroy validates :name, presence: true validates :code, presence: true, uniqueness: true diff --git a/app/models/tournament_transaction_log_entry.rb b/app/models/tournament_transaction_log_entry.rb new file mode 100644 index 0000000..88128f1 --- /dev/null +++ b/app/models/tournament_transaction_log_entry.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TournamentTransactionLogEntry < ApplicationRecord + belongs_to :tournament + belongs_to :user, optional: true + + validates :sequence, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :action, presence: true + validates :message, presence: true +end diff --git a/app/serializers/tournament_transaction_log_entry_serializer.rb b/app/serializers/tournament_transaction_log_entry_serializer.rb new file mode 100644 index 0000000..aa8f708 --- /dev/null +++ b/app/serializers/tournament_transaction_log_entry_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class TournamentTransactionLogEntrySerializer < ApplicationSerializer + attributes :id, :sequence, :action, :message, :metadata + + attribute :created_at do + object.created_at&.iso8601 + end + + attribute :user do + next nil if object.user.nil? + + { + id: object.user.id, + username: object.user.username + } + end +end diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb index a6575c8..f11db45 100644 --- a/app/services/tournament_group_match_starter.rb +++ b/app/services/tournament_group_match_starter.rb @@ -10,13 +10,19 @@ class TournamentGroupMatchStarter selected_lowest_matches(group_matches, lowest_position) end - def start_next!(tournament, logger: Rails.logger) + def start_next!(tournament, logger: Rails.logger, user: nil) started_matches = [] Match.transaction do + TournamentTransactionLogger.lock_tournament!(tournament) started_matches = upcoming_matches(tournament) started_matches.each { |match| match.update!(state: :in_progress) } recalculate_group_scores!(started_matches, logger:) + TournamentTransactionLogger.record_group_matches_started!( + tournament: tournament, + user: user, + matches: started_matches + ) end started_matches diff --git a/app/services/tournament_transaction_logger.rb b/app/services/tournament_transaction_logger.rb new file mode 100644 index 0000000..90b0255 --- /dev/null +++ b/app/services/tournament_transaction_logger.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +class TournamentTransactionLogger + class << self + def lock_tournament!(tournament) + raise ArgumentError, 'tournament is required' if tournament.nil? + + unless ActiveRecord::Base.connection.transaction_open? + raise 'Tournament transaction log writes must happen inside a transaction' + end + + # 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 diff --git a/config/routes.rb b/config/routes.rb index ce2aeb8..3176d07 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ Rails.application.routes.draw do end member do get :timer + get :transaction_log, to: 'tournament_transaction_log_entries#index' patch :timer, action: :set_timer patch :sync_state patch :start_next_group_matches diff --git a/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb b/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb new file mode 100644 index 0000000..ed194c8 --- /dev/null +++ b/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateTournamentTransactionLogEntries < ActiveRecord::Migration[7.0] + def change + create_table :tournament_transaction_log_entries do |t| + t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true + t.references :user, foreign_key: { on_delete: :nullify }, index: true + t.integer :sequence, null: false + t.string :action, null: false + t.text :message, null: false + t.json :metadata, null: false, default: {} + + t.timestamps + end + + add_index :tournament_transaction_log_entries, + [:tournament_id, :sequence], + unique: true, + name: 'index_tournament_log_entries_on_tournament_and_sequence' + end +end diff --git a/db/schema.rb b/db/schema.rb index 262dbe1..773e654 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[8.1].define(version: 2026_04_30_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -147,6 +147,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true end + create_table "tournament_transaction_log_entries", force: :cascade do |t| + t.string "action", null: false + t.datetime "created_at", null: false + t.text "message", null: false + t.json "metadata", default: {}, null: false + t.integer "sequence", null: false + t.integer "tournament_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id" + t.index ["tournament_id", "sequence"], name: "index_tournament_log_entries_on_tournament_and_sequence", unique: true + t.index ["tournament_id"], name: "index_tournament_transaction_log_entries_on_tournament_id" + t.index ["user_id"], name: "index_tournament_transaction_log_entries_on_user_id" + end + create_table "tournaments", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", precision: nil, null: false @@ -221,5 +235,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade add_foreign_key "teams", "tournaments", on_delete: :cascade add_foreign_key "tournament_sync_queue_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournament_transaction_log_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournament_transaction_log_entries", "users", on_delete: :nullify add_foreign_key "tournaments", "users", on_delete: :cascade end diff --git a/doc/tournament_transaction_log_api.md b/doc/tournament_transaction_log_api.md new file mode 100644 index 0000000..b5aa0d7 --- /dev/null +++ b/doc/tournament_transaction_log_api.md @@ -0,0 +1,281 @@ +# Tournament Transaction Log API + +## Purpose + +Tournament transaction log is owner-readable tournament history. + +It records ordered tournament events such as: + +- tournament creation +- timer changes +- match state changes +- match score changes +- decider match creation +- playoff start attempts and failures +- playoff start success +- group match auto-starts + +Frontend should show this as an append-only history feed for tournament owners. + +## Read Flow + +Transaction log is not embedded in normal tournament payload. +Read it from dedicated owner-only endpoint: + +- `GET /tournaments/:id/transaction_log` + +Auth: + +- same Devise token auth headers as other owner APIs +- endpoint requires authenticated tournament owner + +Response: + +- `200 OK` +- JSON array +- ordered by `sequence` ascending +- no pagination yet + +Example: + +```json +[ + { + "id": 17, + "sequence": 1, + "action": "tournament.created", + "message": "owner created tournament Spring Cup(123)", + "metadata": { + "tournament_id": 123, + "tournament_name": "Spring Cup" + }, + "created_at": "2026-04-30T10:12:42Z", + "user": { + "id": 4, + "username": "owner" + } + }, + { + "id": 18, + "sequence": 2, + "action": "match.state_changed", + "message": "owner changed match 77 (Team A(10) vs Team B(11)) to running", + "metadata": { + "match_id": 77, + "stage_type": "playoffs", + "old_state": "not_started", + "new_state": "in_progress", + "team_ids": [10, 11], + "teams": [ + { "id": 10, "name": "Team A" }, + { "id": 11, "name": "Team B" } + ], + "winner_team_id": null, + "advanced_match_ids": [], + "group_scores": [] + }, + "created_at": "2026-04-30T10:13:01Z", + "user": { + "id": 4, + "username": "owner" + } + } +] +``` + +## Field Contract + +Each entry has: + +- `id`: DB id for entry +- `sequence`: per-tournament monotonic order number, starts at `1` +- `action`: machine-readable action key +- `message`: human-readable owner history text +- `metadata`: structured action-specific data +- `created_at`: ISO-8601 timestamp +- `user`: actor object, or `null` for system actions + +`user` shape: + +```json +{ + "id": 4, + "username": "owner" +} +``` + +Frontend display guidance: + +- sort by `sequence`, not by timestamp +- use `message` for first UI pass +- use `metadata` for links/details/chips +- tolerate unknown future `action` values +- tolerate extra `metadata` fields + +## Current Action Keys + +Current `action` values: + +- `tournament.created` +- `tournament.timer_changed` +- `match.state_changed` +- `match_score.updated` +- `decider_match.created` +- `playoffs.start_failed` +- `playoffs.started` +- `group_matches.started` + +Future backend work may add more action keys. +Unknown actions should still render via `message`. + +## Metadata Notes + +`metadata` is stable JSON, but shape depends on `action`. + +Common match metadata includes: + +- `match_id` +- `stage_id` +- `group_id` +- `stage_type`: `groupstage` or `playoffs` +- `team_ids` +- `teams` +- `winner_team_id` +- `advanced_match_ids` +- `group_scores` + +Team object shape: + +```json +{ + "id": 10, + "name": "Team A" +} +``` + +Group score object shape: + +```json +{ + "team_id": 10, + "team_name": "Team A", + "points": 9, + "matches_played": 3, + "wins": 3, + "draws": 0, + "losses": 0, + "score": 12, + "score_against": 4, + "score_difference": 8, + "position": 1 +} +``` + +## Example Actions + +### Match state change + +```json +{ + "action": "match.state_changed", + "message": "owner changed match 77 to finished (Team A(10) wins) - playoffs - Team A(10) advances to match 80", + "metadata": { + "match_id": 77, + "stage_type": "playoffs", + "old_state": "in_progress", + "new_state": "finished", + "winner_team_id": 10, + "advanced_match_ids": [80] + } +} +``` + +### Match score update + +```json +{ + "action": "match_score.updated", + "message": "owner changed matchscore 91 from match 77 to 3 (3 points for Team A(10) in match vs Team B(11))", + "metadata": { + "match_id": 77, + "match_score_id": 91, + "team_id": 10, + "changes": { + "points": { + "from": 2, + "to": 3 + } + } + } +} +``` + +### Playoff start failure + +```json +{ + "action": "playoffs.start_failed", + "message": "owner attempted playoff start, failed because decider missing", + "metadata": { + "stage_id": 12, + "reason": "decider_missing", + "blocking_ties": [ + { + "group_id": 5, + "team_ids": [10, 11], + "teams": [ + { "id": 10, "name": "Team A" }, + { "id": 11, "name": "Team B" } + ] + } + ] + } +} +``` + +### Playoffs started + +```json +{ + "action": "playoffs.started", + "message": "owner started playoffs, advancing teams: Team A(10), Team B(11), Team C(12), Team D(13)", + "metadata": { + "stage_id": 12, + "advancing_team_ids": [10, 11, 12, 13], + "advancing_teams": [ + { "id": 10, "name": "Team A" }, + { "id": 11, "name": "Team B" }, + { "id": 12, "name": "Team C" }, + { "id": 13, "name": "Team D" } + ] + } +} +``` + +## Error Behavior + +Expected errors: + +- `401 Unauthorized`: missing or invalid auth +- `403 Forbidden`: authenticated user is not tournament owner +- `404 Not Found`: tournament id does not exist + +Read-only follower tournaments: + +- log read is allowed for owner +- log write is internal only; frontend has no write endpoint + +## Frontend Implementation Guide + +Recommended owner-history flow: + +1. Load tournament as usual. +2. If current user is owner, request `GET /tournaments/:id/transaction_log`. +3. Render entries sorted by `sequence`. +4. Use `message` as main text. +5. Show `created_at` as event time. +6. Link matches/teams from `metadata` when ids are present. +7. Refresh after owner mutations, or poll while history panel is open. + +There is no websocket stream for transaction log yet. +Existing tournament/match websocket updates still fire for live tournament state. diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index f3d3eb5..dace468 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -148,6 +148,58 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end + def run_transaction_log_history(stop_at: :transaction_log) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 1, teams_per_group: 2, playoff_teams_amount: 1) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + + group_stage = group_stage_of!(tournament) + group = group_stage.fetch(:groups).first + match = group.fetch(:matches).first + team_ids = match.fetch(:match_scores).map { |score| score.dig(:team, :id) } + + play_match_with_points!( + match, + points_by_team_name: { + 'G1-S1' => 1, + 'G1-S2' => 1 + } + ) + checkpoints[:drawn_group_match] = fetch_match!(match.fetch(:id)) + + failed_playoff_start = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect_status!(failed_playoff_start, 422, 'finish group stage without decider') + checkpoints[:failed_playoff_start] = failed_playoff_start.fetch(:json) + + decider = create_decider_match!(group.fetch(:id), team_ids) + play_match_with_hidden_points!( + decider, + hidden_points_by_team_name: { + 'G1-S1' => 2, + 'G1-S2' => 1 + } + ) + checkpoints[:decider_finished] = fetch_match!(decider.fetch(:id)) + + finish_stage!(group_stage.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:playoffs_created] = summarize_tournament(tournament) + + transaction_log = get!("/tournaments/#{tournament.fetch(:id)}/transaction_log") + checkpoints[:transaction_log] = { + actions: transaction_log.map { |entry| entry.fetch(:action) }, + sequences: transaction_log.map { |entry| entry.fetch(:sequence) }, + messages: transaction_log.map { |entry| entry.fetch(:message) } + } + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) session = register_session! tournament = create_tournament!( @@ -339,6 +391,36 @@ module TurniereE2E } end + def play_match_with_hidden_points!(match, hidden_points_by_team_name:, expected_finish_status: 200) + started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + expect_status!(started, 200, "start match #{match.fetch(:id)}") + + refreshed_match = fetch_match!(match.fetch(:id)) + refreshed_match.fetch(:match_scores).each do |score| + team_name = score.dig(:team, :name) + hidden_points = hidden_points_by_team_name.fetch(team_name) do + raise "missing hidden score for #{team_name.inspect} in match #{match.fetch(:id)}" + end + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { hidden_points: hidden_points }) + expect_status!(updated_score, 200, "update hidden match_score #{score.fetch(:id)}") + end + + finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' }) + expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}") + + { + match_id: match.fetch(:id), + status: finished.fetch(:status), + body: finished.fetch(:json) + } + end + + def create_decider_match!(group_id, team_ids) + response = client.post("/groups/#{group_id}/matches", body: { team_ids: team_ids }) + expect_status!(response, 201, "create decider match for group #{group_id}") + response.fetch(:json) + end + def fetch_match!(match_id) response = client.get("/matches/#{match_id}") expect_status!(response, 200, "fetch match #{match_id}") diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index 4a23db8..94f1fc4 100644 --- a/spec/controllers/match_scores_controller_spec.rb +++ b/spec/controllers/match_scores_controller_spec.rb @@ -51,6 +51,23 @@ RSpec.describe MatchScoresController, type: :controller do expect(body[:points]).to eq(valid_update[:points]) expect(body[:hidden_points]).to eq(valid_update[:hidden_points]) end + + it 'writes a transaction log entry for the score change' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match_score.tournament.id) + + expect do + put :update, params: { id: @match_score.to_param }.merge(valid_update) + end.to change { @match_score.tournament.transaction_log_entries.count }.by(1) + + entry = @match_score.tournament.transaction_log_entries.last + expect(entry.action).to eq('match_score.updated') + expect(entry.message).to include("changed matchscore #{@match_score.id}") + expect(entry.metadata['match_score_id']).to eq(@match_score.id) + expect(entry.metadata['changes']['points']).to eq( + 'from' => @match_score.points, + 'to' => valid_update[:points] + ) + end end context 'when match_score update fails' do diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index ed1a5a0..60c92a2 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -101,6 +101,8 @@ RSpec.describe MatchesController, type: :controller do end it 'creates a decider match at the end of the group' do + log_count = group_stage_tournament.transaction_log_entries.count + expect do post :create, params: { group_id: group.to_param, team_ids: team_ids } end.to change { group.matches.reload.count }.by(1) @@ -110,6 +112,8 @@ RSpec.describe MatchesController, type: :controller do expect(created_match.decider?).to eq(true) expect(created_match.state).to eq('not_started') expect(created_match.teams.map(&:id)).to match_array(team_ids) + expect(group_stage_tournament.transaction_log_entries.count).to eq(log_count + 1) + expect(group_stage_tournament.transaction_log_entries.last.action).to eq('decider_match.created') end end @@ -233,6 +237,21 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) end + it 'writes a transaction log entry for the state change' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + + expect do + put :update, params: { id: @match.to_param }.merge(valid_update) + end.to change { @match.tournament.transaction_log_entries.count }.by(1) + + entry = @match.tournament.transaction_log_entries.last + expect(entry.action).to eq('match.state_changed') + expect(entry.sequence).to eq(1) + expect(entry.message).to include("changed match #{@match.id}") + expect(entry.message).to include('running') + expect(entry.metadata['new_state']).to eq('in_progress') + end + it 'stops an in-progress match and resets its scores' do @match.update!(state: :in_progress) @match.match_scores.each do |match_score| diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index bd7ba8c..d447eec 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -97,6 +97,13 @@ RSpec.describe StagesController, type: :controller do tournament = running_group_stage.tournament.reload expect(tournament.team_action_lists).to be_empty end + + it 'writes a transaction log entry for starting playoffs' do + entry = running_group_stage.tournament.transaction_log_entries.last + expect(entry.action).to eq('playoffs.started') + expect(entry.message).to include('started playoffs') + expect(entry.metadata['advancing_team_ids']).to match_array(GroupStageService.get_advancing_teams(running_group_stage).map(&:id)) + end end context 'as another user' do @@ -206,6 +213,13 @@ RSpec.describe StagesController, type: :controller do expect(body[:blocking_ties].size).to eq(1) expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B]) end + + it 'writes a failed playoff start transaction log entry' do + entry = group_stage.tournament.transaction_log_entries.last + expect(entry.action).to eq('playoffs.start_failed') + expect(entry.message).to include('failed because decider missing') + expect(entry.metadata['reason']).to eq('decider_missing') + end end context 'when tied advancing teams still need a decider to fix playoff seeding' do diff --git a/spec/controllers/tournament_transaction_log_entries_controller_spec.rb b/spec/controllers/tournament_transaction_log_entries_controller_spec.rb new file mode 100644 index 0000000..fe07049 --- /dev/null +++ b/spec/controllers/tournament_transaction_log_entries_controller_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentTransactionLogEntriesController, type: :controller do + let(:tournament) { create(:tournament) } + let(:owner) { tournament.owner } + + before do + TournamentTransactionLogger.record!( + tournament: tournament, + user: owner, + action: 'test.entry', + message: 'owner changed match 1', + metadata: { match_id: 1 } + ) + end + + describe 'GET #index' do + context 'as owner' do + before do + apply_authentication_headers_for owner + end + + it 'returns ordered transaction log entries' do + get :index, params: { id: tournament.to_param } + + expect(response).to be_successful + body = deserialize_response(response) + expect(body.size).to eq(1) + expect(body.first[:sequence]).to eq(1) + expect(body.first[:action]).to eq('test.entry') + expect(body.first[:message]).to eq('owner changed match 1') + expect(body.first[:metadata]).to eq(match_id: 1) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'returns forbidden' do + get :index, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 36f8ee2..437c063 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -129,4 +129,21 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished']) end end + + it 'records ordered owner-readable transaction history over HTTP' do + result = runner.run_transaction_log_history + log = result.dig(:checkpoints, :transaction_log) + + expect(log.fetch(:sequences)).to eq((1..log.fetch(:sequences).count).to_a) + expect(log.fetch(:actions)).to include( + 'tournament.created', + 'match.state_changed', + 'match_score.updated', + 'playoffs.start_failed', + 'decider_match.created', + 'playoffs.started' + ) + expect(log.fetch(:messages).join("\n")).to include('failed because decider missing') + expect(log.fetch(:messages).join("\n")).to include('started playoffs, advancing teams:') + end end diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 3cb398a..0c185eb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -41,5 +41,9 @@ RSpec.describe TournamentsController, type: :routing do it 'routes to #test_sync' do expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') end + + it 'routes to transaction log entries' do + expect(get: '/tournaments/1/transaction_log').to route_to('tournament_transaction_log_entries#index', id: '1') + end end end diff --git a/spec/services/tournament_transaction_logger_spec.rb b/spec/services/tournament_transaction_logger_spec.rb new file mode 100644 index 0000000..78e2851 --- /dev/null +++ b/spec/services/tournament_transaction_logger_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentTransactionLogger do + describe '.record!' do + it 'assigns per-tournament sequences in order' do + tournament = create(:tournament) + user = tournament.owner + + first = described_class.record!( + tournament: tournament, + user: user, + action: 'test.first', + message: 'first', + metadata: { number: 1 } + ) + second = described_class.record!( + tournament: tournament, + user: user, + action: 'test.second', + message: 'second', + metadata: { number: 2 } + ) + + expect(first.sequence).to eq(1) + expect(second.sequence).to eq(2) + expect(tournament.transaction_log_entries.order(:sequence).pluck(:message)).to eq(%w[first second]) + end + + it 'does not clear pending after_commit change tracking on the caller tournament' do + tournament = create(:tournament) + user = tournament.owner + old_timer = { timestamp: nil, timer_mode: nil, timer_reason: nil, timer_reason_text: nil } + + Tournament.transaction do + tournament.update!( + timestamp: 2.minutes.from_now, + timer_mode: 'countdown', + timer_reason: 'tournament_start' + ) + new_timer = { timestamp: tournament.timestamp.iso8601, timer_mode: 'countdown', + timer_reason: 'tournament_start', timer_reason_text: nil } + + expect(tournament.saved_change_to_timestamp?).to be(true) + + described_class.record_timer_changed!( + tournament: tournament, + user: user, + old_timer: old_timer, + new_timer: new_timer + ) + + expect(tournament.saved_change_to_timestamp?).to be(true) + end + end + end +end