548 lines
19 KiB
Ruby
548 lines
19 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class TournamentsController < ApplicationController
|
|
class NoOpRequestProfiling
|
|
def measure(_name)
|
|
yield
|
|
end
|
|
|
|
def apply_to(_response, label:)
|
|
label
|
|
end
|
|
end
|
|
|
|
before_action :set_tournament_for_show, only: %i[show]
|
|
before_action :set_tournament,
|
|
only: %i[update destroy destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches]
|
|
before_action :authenticate_user!,
|
|
only: %i[create update destroy destroy_playoffs set_timer test_sync start_next_group_matches]
|
|
before_action -> { require_owner! @tournament.owner },
|
|
only: %i[update destroy destroy_playoffs set_timer test_sync start_next_group_matches]
|
|
before_action :validate_create_params, only: %i[create]
|
|
before_action :validate_update_params, only: %i[update]
|
|
before_action :validate_set_timer_params, only: %i[set_timer]
|
|
before_action :ensure_update_allowed!, only: %i[update]
|
|
before_action :ensure_destroy_allowed!, only: %i[destroy]
|
|
before_action :ensure_timer_update_allowed!, only: %i[set_timer]
|
|
before_action -> { require_writable_tournament!(@tournament) }, only: %i[destroy_playoffs start_next_group_matches]
|
|
before_action :authorize_sync_request!, only: %i[sync_state]
|
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
|
|
|
# GET /tournaments
|
|
def index
|
|
type = index_params.fetch(:type, 'public')
|
|
case type
|
|
when 'public'
|
|
tournaments = Tournament.where(public: true).order(:created_at)
|
|
when 'private'
|
|
tournaments = Tournament.where(owner: current_user, public: false).order(:created_at)
|
|
else
|
|
# invalid type specified
|
|
render json: { error: 'invalid type' }, status: :bad_request
|
|
return
|
|
end
|
|
render json: tournaments, each_serializer: SimpleTournamentSerializer
|
|
end
|
|
|
|
# GET /tournaments/1
|
|
def show
|
|
if show_params.fetch(:simple, 'false') == 'true'
|
|
render json: @tournament, serializer: SimpleTournamentSerializer
|
|
else
|
|
rendered_json = @request_profiling.measure('serialize_tournament') do
|
|
TournamentPayload.build(@tournament, scope: current_user)
|
|
end
|
|
@request_profiling.apply_to(response, label: 'tournament.show')
|
|
render json: rendered_json
|
|
end
|
|
end
|
|
|
|
# POST /tournaments
|
|
def create
|
|
params = tournament_params
|
|
group_stage = params.delete(:group_stage)
|
|
teams = params.delete('teams')
|
|
tournament = current_user.tournaments.new params.except(:teams, :group_stage)
|
|
|
|
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
|
|
end
|
|
return
|
|
end
|
|
|
|
raise ActionController::ParameterMissing, :teams if teams.nil?
|
|
|
|
if group_stage
|
|
params.require(:playoff_teams_amount)
|
|
groups = organize_teams_in_groups(teams)
|
|
# add groups to tournament
|
|
result = AddGroupStageToTournamentAndSave.call(tournament: tournament, groups: groups)
|
|
# associate provided teams with tournament on success
|
|
tournament.teams = groups.flatten if result.success?
|
|
else
|
|
# convert teams parameter into Team objects
|
|
teams = teams.map(&method(:find_or_create_team))
|
|
# associate provided teams with tournament
|
|
tournament.teams = teams
|
|
# add playoff stage to tournament
|
|
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams)
|
|
end
|
|
# validate tournament
|
|
unless tournament.valid?
|
|
render json: tournament.errors, status: :unprocessable_content
|
|
return
|
|
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
|
|
end
|
|
end
|
|
|
|
# PATCH/PUT /tournaments/1
|
|
def update
|
|
synced_tournament = nil
|
|
Tournament.transaction do
|
|
if only_playoff_teams_amount_changed
|
|
@tournament.instant_finalists_amount, @tournament.intermediate_round_participants_amount =
|
|
TournamentService.calculate_default_amount_of_teams_advancing(
|
|
params['playoff_teams_amount'].to_i,
|
|
@tournament.stages.find_by(level: -1).groups.size
|
|
)
|
|
end
|
|
if @tournament.update(tournament_params)
|
|
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
|
|
|
|
# DELETE /tournaments/1
|
|
def destroy
|
|
@tournament.destroy
|
|
end
|
|
|
|
# DELETE /tournaments/:id/playoffs
|
|
def destroy_playoffs
|
|
synced_tournament = nil
|
|
|
|
Tournament.transaction do
|
|
TournamentTransactionLogger.lock_tournament!(@tournament)
|
|
@tournament.reload
|
|
|
|
result = TournamentPlayoffDeletion.call(tournament: @tournament)
|
|
unless result.success?
|
|
render json: { error: result.error }, status: :unprocessable_content
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
synced_tournament = @tournament.reload
|
|
render json: playoff_deletion_payload(synced_tournament)
|
|
end
|
|
|
|
return if synced_tournament.nil?
|
|
|
|
push_sync_if_needed!(synced_tournament)
|
|
TournamentLiveBroadcast.call(synced_tournament.id)
|
|
end
|
|
|
|
# GET /tournaments/:id/timer
|
|
def timer
|
|
render json: serialize_timer_state(@tournament)
|
|
end
|
|
|
|
# PATCH /tournaments/:id/timer
|
|
def set_timer
|
|
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
|
|
TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params)
|
|
TournamentLiveBroadcast.call(@tournament.id)
|
|
render json: @tournament, include: '**'
|
|
rescue KeyError, ArgumentError => e
|
|
render json: { error: e.message }, status: :unprocessable_content
|
|
end
|
|
|
|
def test_sync
|
|
unless @tournament.sync_push_enabled?
|
|
return render json: {
|
|
error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token'
|
|
}, status: :unprocessable_content
|
|
end
|
|
|
|
TournamentSyncPusher.push!(@tournament)
|
|
render json: @tournament.reload, include: '**'
|
|
rescue TournamentSyncPusher::SyncFailed => e
|
|
@tournament.reload
|
|
render json: {
|
|
error: e.message,
|
|
tournament: TournamentSerializer.new(@tournament, scope: current_user).as_json
|
|
}, status: :bad_gateway
|
|
end
|
|
|
|
# PATCH /tournaments/:id/start_next_group_matches
|
|
def start_next_group_matches
|
|
started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:, user: current_user)
|
|
if started_matches.any?
|
|
push_sync_if_needed!(@tournament)
|
|
TournamentLiveBroadcast.call(@tournament.id)
|
|
end
|
|
|
|
render json: ActiveModelSerializers::SerializableResource.new(
|
|
started_matches,
|
|
each_serializer: LiveMatchSerializer,
|
|
include: %w[match_scores.team]
|
|
).as_json
|
|
end
|
|
|
|
private
|
|
|
|
def timer_params
|
|
timer_params = {
|
|
timer_reason: params[:timer_reason],
|
|
timer_reason_text: params[:timer_reason_text]
|
|
}
|
|
timer_params[:timestamp] = params[:timestamp] if params.key?(:timestamp)
|
|
timer_params[:timer_mode] = params[:timer_mode] if params.key?(:timer_mode)
|
|
timer_params
|
|
end
|
|
|
|
def serialize_timer_state(tournament)
|
|
{
|
|
timestamp: tournament.timestamp&.iso8601,
|
|
timer_mode: tournament.timer_mode,
|
|
timer_reason: tournament.timer_reason,
|
|
timer_reason_text: tournament.timer_reason_text
|
|
}
|
|
end
|
|
|
|
def playoff_deletion_payload(tournament)
|
|
{
|
|
id: tournament.id,
|
|
stages: tournament.stages.sort_by(&:level).map do |stage|
|
|
{
|
|
id: stage.id,
|
|
level: stage.level,
|
|
state: stage.state
|
|
}
|
|
end
|
|
}
|
|
end
|
|
|
|
def organize_teams_in_groups(teams)
|
|
# each team gets put into an array of teams depending on the group specified in team[:group]
|
|
teams.group_by { |team| team['group'] }.values.map do |group|
|
|
group.map do |team|
|
|
find_or_create_team(team)
|
|
end
|
|
end
|
|
end
|
|
|
|
def find_or_create_team(team)
|
|
# convert teams parameter into Team objects
|
|
if team[:id]
|
|
Team.find team[:id]
|
|
elsif team[:name]
|
|
Team.create(name: team[:name], players: players_from_team_params(team))
|
|
end
|
|
end
|
|
|
|
def players_from_team_params(team)
|
|
player_emails = team[:player_emails].presence || team[:player_email]
|
|
Array.wrap(player_emails).map(&:to_s).map(&:strip).reject(&:blank?).map do |player_email|
|
|
User.find_or_create_player_by_email!(player_email)
|
|
end
|
|
end
|
|
|
|
def set_tournament
|
|
@tournament = Tournament.find(params[:id])
|
|
end
|
|
|
|
def set_tournament_for_show
|
|
profiling = build_request_profiling
|
|
@tournament = profiling.measure('load_tournament') do
|
|
Tournament.includes(
|
|
:user,
|
|
{ teams: :players },
|
|
stages: [
|
|
{ matches: { match_scores: :team } },
|
|
{ groups: [
|
|
{ matches: { match_scores: :team } },
|
|
{ group_scores: :team }
|
|
] }
|
|
]
|
|
).find(params[:id])
|
|
end
|
|
@request_profiling = profiling
|
|
end
|
|
|
|
def index_params
|
|
params.permit(:type)
|
|
end
|
|
|
|
def show_params
|
|
params.permit(:simple, :profile)
|
|
end
|
|
|
|
def profiling_requested?
|
|
!Rails.env.production? && show_params.fetch(:profile, 'false') == 'true'
|
|
end
|
|
|
|
def build_request_profiling
|
|
return NoOpRequestProfiling.new unless profiling_requested?
|
|
|
|
require Rails.root.join('lib/local/request_profiling')
|
|
Local::RequestProfiling.new(enabled: true)
|
|
end
|
|
|
|
def tournament_params
|
|
params.slice(
|
|
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
|
|
:instant_finalists_amount, :intermediate_round_participants_amount,
|
|
:location, :starts_at, :ends_at, :website_url,
|
|
:read_only_mode, :sync_target_url, :sync_auth_token
|
|
).permit!
|
|
end
|
|
|
|
def validate_create_params
|
|
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
|
|
|
|
teams = params['teams']
|
|
return if teams.is_a?(Array) && teams.count { |team| !team.is_a?(ActionController::Parameters) }.zero?
|
|
|
|
render json: { error: 'Invalid teams array' }, status: :unprocessable_content
|
|
end
|
|
|
|
def only_playoff_teams_amount_changed
|
|
params['playoff_teams_amount'] &&
|
|
params['instant_finalists_amount'].nil? &&
|
|
params['intermediate_round_participants_amount'].nil?
|
|
end
|
|
|
|
def validate_update_params
|
|
return if sync_config_only_update?
|
|
|
|
return if only_playoff_teams_amount_changed
|
|
|
|
playoff_teams_amount = params.key?('playoff_teams_amount') ? params['playoff_teams_amount'].to_i : @tournament.playoff_teams_amount
|
|
instant_finalists_amount = if params.key?('instant_finalists_amount')
|
|
params['instant_finalists_amount'].to_i
|
|
else
|
|
@tournament.instant_finalists_amount
|
|
end
|
|
intermediate_round_participants_amount = if params.key?('intermediate_round_participants_amount')
|
|
params['intermediate_round_participants_amount'].to_i
|
|
else
|
|
@tournament.intermediate_round_participants_amount
|
|
end
|
|
|
|
return if instant_finalists_amount + (intermediate_round_participants_amount / 2) ==
|
|
playoff_teams_amount
|
|
|
|
render json: {
|
|
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
|
|
}, status: :unprocessable_content
|
|
end
|
|
|
|
def sync_state_params
|
|
params.require(:snapshot).permit!.to_h
|
|
end
|
|
|
|
def authorize_sync_request!
|
|
unless @tournament.sync_accepts_push?
|
|
return render json: { error: 'Tournament is not configured to accept sync pushes' },
|
|
status: :unprocessable_content
|
|
end
|
|
|
|
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
|
|
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
|
|
|
|
return if @tournament.sync_token_matches?(token)
|
|
|
|
render json: { error: 'Invalid sync token' }, status: :unauthorized
|
|
end
|
|
|
|
def ensure_update_allowed!
|
|
return unless @tournament.read_only_mode?
|
|
return if sync_config_only_update?
|
|
|
|
render_read_only_error
|
|
end
|
|
|
|
def ensure_destroy_allowed!
|
|
require_writable_tournament!(@tournament)
|
|
end
|
|
|
|
def ensure_timer_update_allowed!
|
|
require_writable_tournament!(@tournament)
|
|
end
|
|
|
|
def sync_config_only_update?
|
|
allowed = %w[id controller action format tournament read_only_mode sync_target_url sync_auth_token]
|
|
(params.keys - allowed).empty?
|
|
end
|
|
|
|
def push_sync_if_needed!(tournament)
|
|
TournamentSyncEnqueue.call(tournament)
|
|
end
|
|
|
|
def validate_set_timer_params
|
|
timestamp = params[:timestamp]
|
|
timestamp_seconds = params[:timestamp_seconds]
|
|
timer_mode = params[:timer_mode]
|
|
timer_reason_param_present = params.key?(:timer_reason)
|
|
timer_reason_text_param_present = params.key?(:timer_reason_text)
|
|
timer_reason_update_requested = timer_reason_param_present || timer_reason_text_param_present
|
|
normalize_timer_reason_params
|
|
timer_reason = params[:timer_reason]
|
|
timer_reason_text = params[:timer_reason_text]
|
|
clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
|
|
|
|
return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) if clear_timer
|
|
|
|
return render_timer_validation_error('Invalid timer_mode') if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
|
|
|
|
timer_reason_error = validate_timer_reason_params(
|
|
timer_reason,
|
|
timer_reason_text,
|
|
timer_reason_param_present,
|
|
timer_reason_text_param_present
|
|
)
|
|
return render_timer_validation_error(timer_reason_error) if timer_reason_error
|
|
|
|
return if validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode)
|
|
|
|
if timer_mode == 'countup'
|
|
params[:timestamp] = Time.zone.now
|
|
elsif timer_mode.present?
|
|
render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
|
|
elsif timer_reason_update_requested
|
|
render_timer_validation_error('timer_reason requires active timer') unless timer_active?
|
|
else
|
|
render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
|
|
end
|
|
end
|
|
|
|
def validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode)
|
|
if timestamp.present? && timestamp_seconds.present?
|
|
return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed')
|
|
end
|
|
|
|
if timer_mode == 'countup' && timestamp_seconds.present?
|
|
return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode')
|
|
end
|
|
|
|
return validate_timestamp_seconds_param(timestamp_seconds) if timestamp_seconds.present?
|
|
return validate_timestamp_param(timestamp, timer_mode) if timestamp.present?
|
|
|
|
false
|
|
end
|
|
|
|
def validate_timestamp_seconds_param(timestamp_seconds)
|
|
seconds = Integer(timestamp_seconds)
|
|
return render_timer_validation_error('Timestamp must be in the future for countdown mode') if seconds <= 0
|
|
|
|
params[:timestamp] = Time.zone.now + seconds
|
|
params[:timer_mode] = 'countdown'
|
|
rescue ArgumentError
|
|
render_timer_validation_error('Invalid seconds format')
|
|
end
|
|
|
|
def validate_timestamp_param(timestamp, timer_mode)
|
|
parsed_time = Time.zone.parse(timestamp)
|
|
if parsed_time.nil?
|
|
return render_timer_validation_error('Invalid timestamp format')
|
|
elsif timer_mode != 'countup' && !parsed_time.future?
|
|
return render_timer_validation_error('Timestamp must be in the future for countdown mode')
|
|
end
|
|
|
|
params[:timestamp] = parsed_time
|
|
params[:timer_mode] = timer_mode.presence || 'countdown'
|
|
rescue ArgumentError
|
|
render_timer_validation_error('Invalid timestamp format')
|
|
end
|
|
|
|
def normalize_timer_reason_params
|
|
params[:timer_reason] = params[:timer_reason].presence
|
|
params[:timer_reason_text] = timer_reason_text_param
|
|
end
|
|
|
|
def timer_reason_text_param
|
|
return nil unless params.key?(:timer_reason_text)
|
|
|
|
params[:timer_reason_text].to_s.strip.presence
|
|
end
|
|
|
|
def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
|
|
if timestamp.present? || timestamp_seconds.present? || timer_mode.present? ||
|
|
timer_reason.present? || timer_reason_text.present?
|
|
return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason')
|
|
end
|
|
|
|
params[:timestamp] = nil
|
|
params[:timer_mode] = nil
|
|
params[:timer_reason] = nil
|
|
params[:timer_reason_text] = nil
|
|
end
|
|
|
|
def validate_timer_reason_params(
|
|
timer_reason,
|
|
timer_reason_text,
|
|
timer_reason_param_present,
|
|
timer_reason_text_param_present
|
|
)
|
|
return 'timer_reason is required when timer_reason_text is provided' if timer_reason_text_param_present && !timer_reason_param_present
|
|
return if timer_reason.blank? && timer_reason_text.blank?
|
|
|
|
return 'timer_reason_text is only allowed when timer_reason is custom' if timer_reason.blank?
|
|
return 'Invalid timer_reason' unless Tournament::TIMER_REASONS.include?(timer_reason)
|
|
|
|
return 'timer_reason_text is required for custom timer_reason' if timer_reason == 'custom' && timer_reason_text.blank?
|
|
|
|
return unless timer_reason != 'custom' && timer_reason_text.present?
|
|
|
|
'timer_reason_text is only allowed when timer_reason is custom'
|
|
end
|
|
|
|
def timer_active?
|
|
@tournament.timestamp.present? && @tournament.timer_mode.present?
|
|
end
|
|
|
|
def render_timer_validation_error(message)
|
|
render json: { error: message }, status: :unprocessable_content
|
|
end
|
|
end
|