Merge branch 'codex/tur-30-transaction-log' into 'master'

TUR-30: Add tournament transaction log

See merge request turniere/turniere-backend!77
This commit is contained in:
Daniel Schädler 2026-04-30 12:26:26 +00:00
commit 547e1c3933
22 changed files with 1276 additions and 91 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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}")

View File

@ -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

View File

@ -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|

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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