feat: add tournament transaction log
This commit is contained in:
parent
6c01343d3a
commit
21e1463b64
|
|
@ -13,14 +13,38 @@ class MatchScoresController < ApplicationController
|
||||||
|
|
||||||
# PATCH/PUT /scores/1
|
# PATCH/PUT /scores/1
|
||||||
def update
|
def update
|
||||||
if @match_score.update(match_score_params)
|
synced_tournament = nil
|
||||||
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match?
|
|
||||||
push_sync_if_needed!(@match_score.tournament)
|
MatchScore.transaction do
|
||||||
TournamentLiveBroadcast.call(@match_score.tournament.id)
|
tournament = @match_score.tournament
|
||||||
render json: @match_score
|
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||||
else
|
@match_score.reload
|
||||||
render json: @match_score.errors, status: :unprocessable_content
|
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
|
end
|
||||||
|
|
||||||
|
return if synced_tournament.nil?
|
||||||
|
|
||||||
|
push_sync_if_needed!(synced_tournament)
|
||||||
|
TournamentLiveBroadcast.call(synced_tournament.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -35,6 +59,30 @@ class MatchScoresController < ApplicationController
|
||||||
params.slice(:points, :hidden_points).permit!
|
params.slice(:points, :hidden_points).permit!
|
||||||
end
|
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)
|
def push_sync_if_needed!(tournament)
|
||||||
TournamentSyncEnqueue.call(tournament)
|
TournamentSyncEnqueue.call(tournament)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -23,37 +23,41 @@ class MatchesController < ApplicationController
|
||||||
|
|
||||||
# POST /groups/:group_id/matches
|
# POST /groups/:group_id/matches
|
||||||
def create
|
def create
|
||||||
match = build_decider_match
|
match = nil
|
||||||
|
synced_tournament = nil
|
||||||
|
|
||||||
if match.errors.any?
|
Match.transaction do
|
||||||
render json: match.errors, status: :unprocessable_content
|
TournamentTransactionLogger.lock_tournament!(@group.tournament)
|
||||||
elsif match.save
|
match = build_decider_match
|
||||||
UpdateGroupsGroupScoresAndSave.call(group: @group)
|
|
||||||
push_sync_if_needed!(@group.tournament)
|
if match.errors.any?
|
||||||
TournamentLiveBroadcast.call(@group.tournament.id)
|
render json: match.errors, status: :unprocessable_content
|
||||||
render json: match, include: %w[match_scores.team group], status: :created
|
raise ActiveRecord::Rollback
|
||||||
else
|
elsif match.save
|
||||||
render json: match.errors, status: :unprocessable_content
|
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
|
end
|
||||||
|
|
||||||
|
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||||
|
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH/PUT /matches/1
|
# PATCH/PUT /matches/1
|
||||||
def update
|
def update
|
||||||
new_state = match_params['state']
|
new_state = match_params['state']
|
||||||
synced_tournament = nil
|
synced_tournament = nil
|
||||||
|
@transaction_log_group_scores = []
|
||||||
|
@transaction_log_advanced_match_ids = []
|
||||||
|
|
||||||
Match.transaction do
|
Match.transaction do
|
||||||
if @match.update(match_params)
|
synced_tournament = update_match_inside_transaction(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)
|
|
||||||
|
|
||||||
render json: @match
|
|
||||||
synced_tournament = @match.tournament
|
|
||||||
else
|
|
||||||
render json: @match.errors, status: :unprocessable_content
|
|
||||||
raise ActiveRecord::Rollback
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||||
TournamentLiveBroadcast.call(synced_tournament.id) 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?
|
unless UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||||
logger.warn "Updating groups group score failed for #{group}"
|
logger.warn "Updating groups group score failed for #{group}"
|
||||||
end
|
end
|
||||||
|
@transaction_log_group_scores = group_scores_for_log(group)
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
return if @match.group_match?
|
|
||||||
|
|
||||||
if @match.winner.nil?
|
if @match.winner.nil?
|
||||||
render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' },
|
render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' },
|
||||||
status: :unprocessable_content
|
status: :unprocessable_content
|
||||||
raise ActiveRecord::Rollback
|
raise ActiveRecord::Rollback
|
||||||
end
|
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' },
|
render json: { error: 'Moving Team one stage down failed' },
|
||||||
status: :unprocessable_content
|
status: :unprocessable_content
|
||||||
|
|
@ -117,27 +125,62 @@ class MatchesController < ApplicationController
|
||||||
|
|
||||||
def update_group_scores
|
def update_group_scores
|
||||||
group = @match.group
|
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}"
|
logger.warn "Updating groups group score failed for #{group}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_params
|
def update_match_inside_transaction(new_state)
|
||||||
case match_params['state']
|
tournament = @match.tournament
|
||||||
when 'in_progress'
|
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||||
render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_content \
|
@match.reload
|
||||||
unless @match.not_started?
|
validate_transition_inside_transaction(new_state)
|
||||||
when 'finished'
|
|
||||||
render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_content \
|
old_state = @match.state
|
||||||
unless @match.in_progress?
|
if @match.update(match_params)
|
||||||
when 'not_started'
|
record_successful_match_update(old_state, new_state)
|
||||||
render json: { error: 'Match can\'t stop in this state' }, status: :unprocessable_content \
|
render json: @match
|
||||||
unless @match.in_progress?
|
tournament
|
||||||
else
|
else
|
||||||
render json: { error: 'Invalid target state' }, status: :unprocessable_content
|
render json: @match.errors, status: :unprocessable_content
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
end
|
end
|
||||||
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
|
def set_match
|
||||||
@match = Match.find(params[:id])
|
@match = Match.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
@ -186,6 +229,27 @@ class MatchesController < ApplicationController
|
||||||
end
|
end
|
||||||
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)
|
def push_sync_if_needed!(tournament)
|
||||||
TournamentSyncEnqueue.call(tournament)
|
TournamentSyncEnqueue.call(tournament)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,43 +13,81 @@ class StagesController < ApplicationController
|
||||||
|
|
||||||
# PUT /stages/1
|
# PUT /stages/1
|
||||||
def update
|
def update
|
||||||
if stage_params[:state] == 'finished'
|
return render_invalid_stage_state unless stage_params[:state] == 'finished'
|
||||||
unless @stage.state == 'in_progress'
|
return render_stage_not_running unless @stage.in_progress?
|
||||||
render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
synced_tournament = nil
|
finish_group_stage
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def handle_group_stage_end
|
def finish_group_stage
|
||||||
unless @stage.over?
|
synced_tournament = nil
|
||||||
render json: {
|
@playoff_start_failure = nil
|
||||||
error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs'
|
@transaction_log_advancing_teams = []
|
||||||
}, status: :unprocessable_content
|
|
||||||
|
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
|
raise ActiveRecord::Rollback
|
||||||
end
|
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|
|
@stage.groups.each do |group|
|
||||||
next if UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
next if UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||||
|
|
||||||
|
|
@ -57,20 +95,56 @@ class StagesController < ApplicationController
|
||||||
raise ActiveRecord::Rollback
|
raise ActiveRecord::Rollback
|
||||||
end
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def no_blocking_ties_for_playoff_start?
|
||||||
blocking_ties = GroupStageService.blocking_ties_for(@stage)
|
blocking_ties = GroupStageService.blocking_ties_for(@stage)
|
||||||
if blocking_ties.any?
|
return true if blocking_ties.empty?
|
||||||
render json: {
|
|
||||||
error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place',
|
render_playoff_start_failure(reason: 'decider_missing', blocking_ties: blocking_ties)
|
||||||
blocking_ties: blocking_ties
|
false
|
||||||
}, status: :unprocessable_content
|
end
|
||||||
raise ActiveRecord::Rollback
|
|
||||||
|
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
|
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
|
def record_playoffs_started
|
||||||
raise ActiveRecord::Rollback
|
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
|
end
|
||||||
|
|
||||||
def set_stage
|
def set_stage
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -65,6 +65,7 @@ class TournamentsController < ApplicationController
|
||||||
|
|
||||||
if tournament.read_only_mode?
|
if tournament.read_only_mode?
|
||||||
if tournament.save
|
if tournament.save
|
||||||
|
TournamentTransactionLogger.record_tournament_created!(tournament: tournament, user: current_user)
|
||||||
render json: tournament, status: :created, location: tournament
|
render json: tournament, status: :created, location: tournament
|
||||||
else
|
else
|
||||||
render json: tournament.errors, status: :unprocessable_content
|
render json: tournament.errors, status: :unprocessable_content
|
||||||
|
|
@ -95,6 +96,7 @@ class TournamentsController < ApplicationController
|
||||||
end
|
end
|
||||||
# return appropriate result
|
# return appropriate result
|
||||||
if result.success?
|
if result.success?
|
||||||
|
TournamentTransactionLogger.record_tournament_created!(tournament: result.tournament, user: current_user)
|
||||||
render json: result.tournament, status: :created, location: result.tournament
|
render json: result.tournament, status: :created, location: result.tournament
|
||||||
else
|
else
|
||||||
render json: { error: 'Tournament generation failed' }, status: :unprocessable_content
|
render json: { error: 'Tournament generation failed' }, status: :unprocessable_content
|
||||||
|
|
@ -135,12 +137,29 @@ class TournamentsController < ApplicationController
|
||||||
|
|
||||||
# PATCH /tournaments/:id/timer
|
# PATCH /tournaments/:id/timer
|
||||||
def set_timer
|
def set_timer
|
||||||
if @tournament.update(timer_params)
|
synced_tournament = nil
|
||||||
push_sync_if_needed!(@tournament)
|
|
||||||
render json: @tournament
|
Tournament.transaction do
|
||||||
else
|
TournamentTransactionLogger.lock_tournament!(@tournament)
|
||||||
render json: @tournament.errors, status: :unprocessable_content
|
@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
|
end
|
||||||
|
|
||||||
|
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_state
|
def sync_state
|
||||||
|
|
@ -170,7 +189,7 @@ class TournamentsController < ApplicationController
|
||||||
|
|
||||||
# PATCH /tournaments/:id/start_next_group_matches
|
# PATCH /tournaments/:id/start_next_group_matches
|
||||||
def 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?
|
if started_matches.any?
|
||||||
push_sync_if_needed!(@tournament)
|
push_sync_if_needed!(@tournament)
|
||||||
TournamentLiveBroadcast.call(@tournament.id)
|
TournamentLiveBroadcast.call(@tournament.id)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class Tournament < ApplicationRecord
|
||||||
has_many :stages, dependent: :destroy
|
has_many :stages, dependent: :destroy
|
||||||
has_many :beamers, dependent: :destroy
|
has_many :beamers, dependent: :destroy
|
||||||
has_many :team_action_lists, dependent: :destroy
|
has_many :team_action_lists, dependent: :destroy
|
||||||
|
has_many :transaction_log_entries, class_name: 'TournamentTransactionLogEntry', dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :code, presence: true, uniqueness: true
|
validates :code, presence: true, uniqueness: true
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -10,13 +10,19 @@ class TournamentGroupMatchStarter
|
||||||
selected_lowest_matches(group_matches, lowest_position)
|
selected_lowest_matches(group_matches, lowest_position)
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_next!(tournament, logger: Rails.logger)
|
def start_next!(tournament, logger: Rails.logger, user: nil)
|
||||||
started_matches = []
|
started_matches = []
|
||||||
|
|
||||||
Match.transaction do
|
Match.transaction do
|
||||||
|
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||||
started_matches = upcoming_matches(tournament)
|
started_matches = upcoming_matches(tournament)
|
||||||
started_matches.each { |match| match.update!(state: :in_progress) }
|
started_matches.each { |match| match.update!(state: :in_progress) }
|
||||||
recalculate_group_scores!(started_matches, logger:)
|
recalculate_group_scores!(started_matches, logger:)
|
||||||
|
TournamentTransactionLogger.record_group_matches_started!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
matches: started_matches
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
started_matches
|
started_matches
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
tournament.lock!
|
||||||
|
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
|
||||||
|
|
@ -32,6 +32,7 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
member do
|
member do
|
||||||
get :timer
|
get :timer
|
||||||
|
get :transaction_log, to: 'tournament_transaction_log_entries#index'
|
||||||
patch :timer, action: :set_timer
|
patch :timer, action: :set_timer
|
||||||
patch :sync_state
|
patch :sync_state
|
||||||
patch :start_next_group_matches
|
patch :start_next_group_matches
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
20
db/schema.rb
20
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do
|
||||||
create_table "beamers", force: :cascade do |t|
|
create_table "beamers", force: :cascade do |t|
|
||||||
t.json "config", default: {}, null: false
|
t.json "config", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|
@ -147,6 +147,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
||||||
t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true
|
t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true
|
||||||
end
|
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|
|
create_table "tournaments", force: :cascade do |t|
|
||||||
t.string "code", null: false
|
t.string "code", null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
|
@ -164,9 +178,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
||||||
t.integer "sync_source_tournament_id"
|
t.integer "sync_source_tournament_id"
|
||||||
t.string "sync_target_url"
|
t.string "sync_target_url"
|
||||||
t.string "timer_mode"
|
t.string "timer_mode"
|
||||||
t.datetime "timestamp"
|
|
||||||
t.string "timer_reason"
|
t.string "timer_reason"
|
||||||
t.text "timer_reason_text"
|
t.text "timer_reason_text"
|
||||||
|
t.datetime "timestamp"
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
||||||
|
|
@ -220,5 +234,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
||||||
add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade
|
add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade
|
||||||
add_foreign_key "teams", "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_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
|
add_foreign_key "tournaments", "users", on_delete: :cascade
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,58 @@ module TurniereE2E
|
||||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
end
|
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)
|
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
|
||||||
session = register_session!
|
session = register_session!
|
||||||
tournament = create_tournament!(
|
tournament = create_tournament!(
|
||||||
|
|
@ -339,6 +391,36 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
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)
|
def fetch_match!(match_id)
|
||||||
response = client.get("/matches/#{match_id}")
|
response = client.get("/matches/#{match_id}")
|
||||||
expect_status!(response, 200, "fetch match #{match_id}")
|
expect_status!(response, 200, "fetch match #{match_id}")
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,23 @@ RSpec.describe MatchScoresController, type: :controller do
|
||||||
expect(body[:points]).to eq(valid_update[:points])
|
expect(body[:points]).to eq(valid_update[:points])
|
||||||
expect(body[:hidden_points]).to eq(valid_update[:hidden_points])
|
expect(body[:hidden_points]).to eq(valid_update[:hidden_points])
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when match_score update fails' do
|
context 'when match_score update fails' do
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ RSpec.describe MatchesController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a decider match at the end of the group' do
|
it 'creates a decider match at the end of the group' do
|
||||||
|
log_count = group_stage_tournament.transaction_log_entries.count
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
post :create, params: { group_id: group.to_param, team_ids: team_ids }
|
post :create, params: { group_id: group.to_param, team_ids: team_ids }
|
||||||
end.to change { group.matches.reload.count }.by(1)
|
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.decider?).to eq(true)
|
||||||
expect(created_match.state).to eq('not_started')
|
expect(created_match.state).to eq('not_started')
|
||||||
expect(created_match.teams.map(&:id)).to match_array(team_ids)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -233,6 +237,21 @@ RSpec.describe MatchesController, type: :controller do
|
||||||
expect(body[:state]).to eq(valid_update[:state])
|
expect(body[:state]).to eq(valid_update[:state])
|
||||||
end
|
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
|
it 'stops an in-progress match and resets its scores' do
|
||||||
@match.update!(state: :in_progress)
|
@match.update!(state: :in_progress)
|
||||||
@match.match_scores.each do |match_score|
|
@match.match_scores.each do |match_score|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,13 @@ RSpec.describe StagesController, type: :controller do
|
||||||
tournament = running_group_stage.tournament.reload
|
tournament = running_group_stage.tournament.reload
|
||||||
expect(tournament.team_action_lists).to be_empty
|
expect(tournament.team_action_lists).to be_empty
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'as another user' do
|
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].size).to eq(1)
|
||||||
expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
|
expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when tied advancing teams still need a decider to fix playoff seeding' do
|
context 'when tied advancing teams still need a decider to fix playoff seeding' do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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'])
|
expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished'])
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,9 @@ RSpec.describe TournamentsController, type: :routing do
|
||||||
it 'routes to #test_sync' do
|
it 'routes to #test_sync' do
|
||||||
expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1')
|
expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1')
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# 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
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue