Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 15:05:57 +02:00
co-authored by Cursor
parent d52434fb2e
commit a1f4c24a43
124 changed files with 6979 additions and 91 deletions
@@ -35,9 +35,9 @@ module Recordings
FileUtils.remove_entry(dest)
return nil
end
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
unless res.is_a?(Net::HTTPSuccess) && body_present?(res.body)
FileUtils.remove_entry(dest)
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
raise Error, "agent GET recordings HTTP #{res.code} #{safe_body_snippet(res.body)}"
end
tar_path = File.join(dest, "recordings.tar.gz")
@@ -84,6 +84,14 @@ module Recordings
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def body_present?(body)
body && !body.empty?
end
def safe_body_snippet(body)
body.to_s.dup.force_encoding("UTF-8").scrub[0, 200]
end
def unpack!(tar_path, dest)
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
raise Error, "tar extract failed" unless ok
+5
View File
@@ -15,6 +15,11 @@ module Sessions
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
remove_mediamtx_paths!
begin
Tournaments::CaptureStreamResult.call(@session)
rescue StandardError => e
Rails.logger.warn("[Sessions::Stop] tournament result: #{e.class}: #{e.message}")
end
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session
+12 -1
View File
@@ -71,6 +71,10 @@ module Streams
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end
def quiet_hours?
QuietHours.active?
end
def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
@@ -106,7 +110,8 @@ module Streams
allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size)
within_budget: within_budget?(overflow.size),
quiet_hours: quiet_hours?
}
end
@@ -201,6 +206,7 @@ module Streams
end
def warm_spare_desired?(m)
return false if self.class.quiet_hours?
return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use?
@@ -211,6 +217,7 @@ module Streams
end
def can_provision?(m)
return false if self.class.quiet_hours?
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
@@ -249,6 +256,9 @@ module Streams
end
def idle_long_enough?(node)
# Di notte chiudi subito i nodi idle (niente attesa IDLE_MINUTES).
return true if self.class.quiet_hours?
idle_since(node) <= self.class.idle_minutes.minutes.ago
end
@@ -258,6 +268,7 @@ module Streams
end
def keep_as_warm_spare?(node)
return false if self.class.quiet_hours?
return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
@@ -0,0 +1,83 @@
# frozen_string_literal: true
module Streams
# Durante quiet hours chiude CPX cloud idle; se hanno sessioni attive solo alert Ops.
class NightCloudSweeper
Result = Struct.new(:skipped, :actions, :error, keyword_init: true)
class << self
def enabled?
ENV.fetch("STREAM_NIGHT_SWEEP_ENABLED", "1") == "1"
end
def sweep!(provisioner: nil)
new(provisioner: provisioner).sweep!
end
end
def initialize(provisioner: nil)
@provisioner = provisioner || NodeProvisioner.new
end
def sweep!
unless self.class.enabled?
return Result.new(skipped: true, actions: [], error: "disabled")
end
unless QuietHours.active?
return Result.new(skipped: true, actions: [], error: "outside_quiet_hours")
end
actions = []
cloud_nodes.find_each do |node|
if node.occupying_sessions.exists?
alert_active_night_node!(node)
actions << :"alert_active_#{node.slug}"
next
end
close_idle_node!(node)
actions << :"decommission_#{node.slug}"
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::NightCloudSweeper] #{node.slug}: #{e.message}")
actions << :"error_#{node.slug}"
end
Rails.logger.info("[Streams::NightCloudSweeper] actions=#{actions.inspect}")
Result.new(skipped: false, actions: actions)
end
private
def cloud_nodes
StreamNode.where(role: "cloud").where(status: %w[ready draining provisioning])
end
def alert_active_night_node!(node)
Ops::IncidentRecorder.record(
{
kind: "stream_overflow",
severity: "warning",
title: "CPX attivo di notte — verificare evento",
message: "Nodo #{node.slug} ha #{node.active_publishers} sessione/i in quiet hours (#{QuietHours.range_config} #{QuietHours.time_zone_name})",
metadata: {
"slug" => node.slug,
"session_ids" => node.occupying_sessions.pluck(:id),
"quiet_hours" => QuietHours.range_config
},
fingerprint: "stream_overflow:night_active:#{node.slug}"
}
)
end
def close_idle_node!(node)
@provisioner.drain!(node) unless node.status == "draining"
node.reload
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
slug = node.slug
@provisioner.decommission!(node)
Rails.logger.warn("[Streams::NightCloudSweeper] decommissioned idle cloud node #{slug} during quiet hours")
Ops::IncidentRecorder.resolve(fingerprint: "stream_overflow:night_active:#{slug}")
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Streams
# Finestra notturna (default 02:0007:00 Europe/Rome) in cui non devono restare CPX idle.
module QuietHours
DEFAULT_RANGE = "02:00-07:00"
DEFAULT_TZ = "Europe/Rome"
module_function
def range_config
ENV.fetch("STREAM_AUTOSCALE_QUIET_HOURS", DEFAULT_RANGE).to_s.strip
end
def time_zone_name
ENV.fetch("STREAM_AUTOSCALE_QUIET_TZ", DEFAULT_TZ)
end
def configured?
range_config.present? && range_config != "off" && range_config != "0"
end
def active?(now: Time.current)
return false unless configured?
zone = ActiveSupport::TimeZone[time_zone_name] || Time.find_zone!(time_zone_name)
local = now.in_time_zone(zone)
start_min, end_min = parse_range(range_config)
return false if start_min.nil? || end_min.nil?
current = local.hour * 60 + local.min
if start_min <= end_min
current >= start_min && current < end_min
else
# es. 22:00-06:00
current >= start_min || current < end_min
end
rescue ArgumentError => e
Rails.logger.warn("[Streams::QuietHours] invalid config: #{e.message}")
false
end
def parse_range(raw)
start_s, end_s = raw.split("-", 2).map { |p| p.to_s.strip }
return [nil, nil] if start_s.blank? || end_s.blank?
[parse_hhmm(start_s), parse_hhmm(end_s)]
end
def parse_hhmm(value)
h, m = value.split(":", 2)
hours = Integer(h)
mins = Integer(m || 0)
raise ArgumentError, "ora fuori range: #{value}" unless hours.between?(0, 23) && mins.between?(0, 59)
hours * 60 + mins
end
end
end
@@ -45,7 +45,7 @@ module Teams
end
def base_teams
scope = Team.includes(:club).where(id: active_team_ids)
scope = Team.visible.includes(:club).where(id: active_team_ids)
scope = scope.where(sport_key: @sport) if @sport
scope = apply_search(scope) if @q.present?
scope.to_a
@@ -0,0 +1,35 @@
module Tournaments
class CaptureStreamResult
def self.call(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
match = @session.match
return unless match&.tournament_match?
score = @session.score_state
return unless score
board = match.effective_board_type
home, away, extra = case board
when "basket", "timed"
[score.basket_home_score, score.basket_away_score, { "board" => board }]
else
[score.home_sets, score.away_sets, { "board" => board, "partials" => score.set_partials }]
end
Tournaments::RecordResult.call(
match: match,
home_score: home,
away_score: away,
source: "stream",
result_data: extra
)
end
end
end
@@ -0,0 +1,118 @@
# frozen_string_literal: true
module Tournaments
class ComposeInviteEmail
LINK_TOKEN = "{{link_invito}}"
EXPIRY_TOKEN = "{{scadenza}}"
MAX_HTML_BYTES = 100_000
ALLOWED_TAGS = %w[p br strong b em i u ul ol li a img h2 h3 h4 span div blockquote].freeze
ALLOWED_ATTR = %w[href src alt width height style target rel data-invite-note].freeze
ALLOWED_STYLE = %w[
width height max-width min-width margin margin-top margin-bottom margin-left margin-right
padding padding-top padding-bottom padding-left padding-right
float display text-align color background background-color
font-weight font-size line-height text-decoration border-radius border
].freeze
def self.call(html:, invite_url:, expires_on: nil)
new(html: html, invite_url: invite_url, expires_on: expires_on).call
end
def self.sanitize_html(html)
new(html: html.to_s, invite_url: "").sanitize(html.to_s)
end
def self.default_html(tournament:, invited_by:)
intro = I18n.t(
"mailers.tournament_invite.body_html",
inviter: CGI.escapeHTML(invited_by.name.to_s),
tournament: CGI.escapeHTML(tournament.name.to_s),
club: CGI.escapeHTML(tournament.club.name.to_s),
assignment: I18n.t("tournaments.hub.invite_email_scope_generic")
)
cta = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
<<~HTML
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}</p>
<p>#{intro}</p>
<p><a href="#{LINK_TOKEN}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{cta}</a></p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}<br>#{LINK_TOKEN}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}</p>
HTML
end
def initialize(html:, invite_url:, expires_on: nil)
@html = html.to_s
@invite_url = invite_url.to_s
@expires_on = expires_on.to_s
end
def call
html = @html.dup
html.gsub!(LINK_TOKEN, @invite_url)
html.gsub!(EXPIRY_TOKEN, @expires_on) if @expires_on.present?
sanitized = sanitize(html)
sanitized += fallback_cta if @invite_url.present? && !sanitized.include?(@invite_url)
sanitized
end
def sanitize(html)
html = html.bytesize > MAX_HTML_BYTES ? html.byteslice(0, MAX_HTML_BYTES) : html
fragment = Loofah.fragment(html.to_s)
fragment.css("[data-invite-note]").each do |el|
el.remove if el.text.to_s.strip.blank?
end
scrubber = Rails::HTML::PermitScrubber.new
scrubber.tags = ALLOWED_TAGS
scrubber.attributes = ALLOWED_ATTR
fragment.scrub!(scrubber)
fragment.css("a").each { |node| scrub_url!(node, "href") }
fragment.css("img").each do |node|
scrub_url!(node, "src")
end
fragment.css("[style]").each { |node| scrub_style!(node) }
fragment.css("a[target='_blank']").each do |node|
node["rel"] = "noopener noreferrer"
end
fragment.to_s
end
private
def scrub_url!(node, attr)
url = node[attr].to_s.strip
return if url == LINK_TOKEN || url.include?(LINK_TOKEN)
return if url.match?(/\Ahttps?:\/\//i) || url.start_with?("/rails/active_storage")
if attr == "src"
node.remove
else
node.remove_attribute(attr)
end
end
def scrub_style!(node)
decls = node["style"].to_s.split(";").map(&:strip).reject(&:blank?)
kept = decls.select do |decl|
prop, value = decl.split(":", 2).map { |part| part.to_s.strip }
next false if prop.blank? || value.blank?
next false unless ALLOWED_STYLE.include?(prop.downcase)
next false if value.match?(/expression|javascript|url\s*\(/i)
true
end
if kept.any?
node["style"] = kept.join("; ")
else
node.remove_attribute("style")
end
end
def fallback_cta
label = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
url = CGI.escapeHTML(@invite_url)
%(<p><a href="#{url}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{label}</a></p><p>#{url}</p>)
end
end
end
@@ -0,0 +1,87 @@
module Tournaments
class Create
KNOCKOUT_ROUNDS = {
16 => %w[round_of_16 quarterfinal semifinal final],
8 => %w[quarterfinal semifinal final],
4 => %w[semifinal final],
2 => %w[final]
}.freeze
ROUND_LABELS = {
"round_of_16" => "Ottavi",
"quarterfinal" => "Quarti",
"semifinal" => "Semifinali",
"final" => "Finale",
"third_place" => "Finale 3° posto"
}.freeze
def self.call(club:, attrs:)
new(club: club, attrs: attrs).call
end
def initialize(club:, attrs:)
@club = club
@attrs = attrs
end
def call
Tournaments::Entitlements.new(@club).assert_creatable!
tournament = @club.tournaments.build(filtered_attrs)
tournament.sport_key = Sports::Catalog.normalize_key(tournament.sport_key.presence || @club.sport)
Tournament.transaction do
tournament.save!
attach_logo!(tournament)
Tournaments::EnsureBroadcastTeam.call(tournament)
create_default_groups!(tournament)
create_knockout_rounds!(tournament)
end
tournament
end
private
def filtered_attrs
@attrs.to_h.symbolize_keys.slice(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts
).tap do |h|
h[:courts] = parse_courts(h[:courts]) if h.key?(:courts)
h[:knockout_size] = h[:knockout_size].to_i if h[:knockout_size].present?
h[:knockout_size] = nil if h[:knockout_size].to_i <= 0
end
end
def parse_courts(value)
case value
when Array then value
else value.to_s.split(/[\n,]/)
end
end
def attach_logo!(tournament)
file = @attrs.to_h.symbolize_keys[:logo_file]
tournament.logo_file.attach(file) if file.present?
end
def create_default_groups!(tournament)
return unless tournament.uses_groups?
return if tournament.groups.exists?
["Girone A", "Girone B"].each_with_index do |name, idx|
tournament.groups.create!(name: name, position: idx)
end
end
def create_knockout_rounds!(tournament)
return unless tournament.uses_knockout?
return if tournament.rounds.exists?
size = tournament.knockout_size.presence || 4
kinds = KNOCKOUT_ROUNDS[size] || KNOCKOUT_ROUNDS[4]
kinds.each_with_index do |kind, idx|
tournament.rounds.create!(kind: kind, name: ROUND_LABELS[kind], position: idx)
end
end
end
end
@@ -0,0 +1,46 @@
module Tournaments
class Destroy
class LiveBroadcastError < StandardError; end
def self.call(tournament:)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
raise LiveBroadcastError, I18n.t("flash.tournaments.delete_blocked_live") if live_broadcast?
team = @tournament.broadcast_team
Tournament.transaction do
purge_recordings!
@tournament.update_column(:broadcast_team_id, nil) if team
@tournament.destroy!
destroy_orphan_broadcast_team!(team)
end
end
private
def live_broadcast?
StreamSession.where(
match_id: @tournament.matches.select(:id),
status: %w[connecting live reconnecting paused]
).exists?
end
def purge_recordings!
session_ids = StreamSession.where(match_id: @tournament.matches.select(:id)).select(:id)
Recording.where(stream_session_id: session_ids).find_each(&:destroy!)
end
def destroy_orphan_broadcast_team!(team)
return unless team&.tournament_broadcast?
return if Tournament.exists?(broadcast_team_id: team.id)
team.destroy!
end
end
end
@@ -0,0 +1,23 @@
module Tournaments
class EnsureBroadcastTeam
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
return @tournament.broadcast_team if @tournament.broadcast_team.present?
team = @tournament.club.teams.create!(
name: @tournament.name,
sport_key: @tournament.sport_key,
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
@tournament.update!(broadcast_team: team)
team
end
end
end
@@ -0,0 +1,43 @@
module Tournaments
class EntitlementError < StandardError
attr_reader :code, :billing_url
def initialize(message, code:, billing_url: nil)
super(message)
@code = code
@billing_url = billing_url
end
end
class Entitlements
def initialize(club)
@club = club
end
def subscription
@subscription ||= @club.subscription
end
def premium_full?
subscription&.premium_full? == true
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def assert_creatable!
assert_writable!
end
def assert_writable!
unless premium_full?
raise EntitlementError.new(
"I tornei sono disponibili con il piano Premium Full.",
code: "premium_full_required",
billing_url: billing_url
)
end
end
end
end
@@ -0,0 +1,40 @@
module Tournaments
class FillKnockoutSources
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
@tournament.matches.find_each do |match|
next if match.home_source_kind.blank? && match.away_source_kind.blank?
home = resolve(match, :home)
away = resolve(match, :away)
attrs = {}
attrs[:home_participant] = home if home && match.home_participant_id.blank?
attrs[:away_participant] = away if away && match.away_participant_id.blank?
match.update!(attrs) if attrs.any?
end
end
private
def resolve(match, side)
kind = match.public_send("#{side}_source_kind")
case kind
when "winner_match"
source = Match.find_by(id: match.public_send("#{side}_source_match_id"))
source&.winner_participant
when "group_rank"
group = TournamentGroup.find_by(id: match.public_send("#{side}_source_group_id"))
rank = match.public_send("#{side}_source_rank").to_i
return nil unless group && rank.positive?
Tournaments::Standings.call(group)[rank - 1]&.participant
end
end
end
end
@@ -0,0 +1,72 @@
module Tournaments
class GenerateGroupMatches
def self.call(tournament:, start_at: nil)
new(tournament, start_at: start_at).call
end
def initialize(tournament, start_at: nil)
@tournament = tournament
@start_at = start_at
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
created = []
cursor = @start_at || Time.zone.local(@tournament.starts_on.year, @tournament.starts_on.month, @tournament.starts_on.day, 9, 0, 0)
courts = @tournament.court_list
court_idx = 0
@tournament.groups.includes(:participants).order(:position).each do |group|
pairs = round_robin(group.participants.to_a)
pairs.each do |home, away|
next if home.blank? || away.blank?
match = Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
tournament_group_id: group.id,
court: courts[court_idx % courts.size],
scheduled_at: cursor
}
)
created << match
court_idx += 1
if (court_idx % courts.size).zero?
cursor += 1.hour
cursor = next_day_morning(cursor) if cursor.to_date > @tournament.ends_on
end
end
end
created
end
private
def round_robin(participants)
list = participants.dup
return [] if list.size < 2
list << nil if list.size.odd?
n = list.size
rounds = n - 1
pairs = []
rounds.times do
(n / 2).times do |i|
a = list[i]
b = list[n - 1 - i]
pairs << [a, b] if a && b
end
list = [list[0]] + [list[-1]] + list[1..-2]
end
pairs
end
def next_day_morning(time)
nxt = time.to_date + 1.day
nxt = @tournament.starts_on if nxt > @tournament.ends_on
Time.zone.local(nxt.year, nxt.month, nxt.day, 9, 0, 0)
end
end
end
@@ -0,0 +1,30 @@
module Tournaments
class GenerateSlug
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
base = @tournament.name.to_s.parameterize.presence || "torneo"
slug = base
n = 2
while conflict?(slug)
slug = "#{base}-#{n}"
n += 1
end
slug
end
private
def conflict?(slug)
scope = Tournament.where(slug: slug)
scope = scope.where.not(id: @tournament.id) if @tournament.persisted?
scope.exists?
end
end
end
@@ -0,0 +1,21 @@
module Tournaments
class GrantBroadcastAccess
def self.call(invitation:, user:)
new(invitation: invitation, user: user).call
end
def initialize(invitation:, user:)
@invitation = invitation
@user = user
end
def call
team = Tournaments::EnsureBroadcastTeam.call(@invitation.tournament)
membership = UserTeam.find_or_initialize_by(user: @user, team: team)
membership.role = "member" if membership.new_record?
membership.staff_kind = "transmission"
membership.save!
membership
end
end
end
@@ -0,0 +1,45 @@
module Tournaments
class Invite
def self.call(tournament:, email:, scope_kind:, match_ids: [], court: nil, on_date: nil, invited_by:, note: nil)
new(
tournament: tournament,
email: email,
scope_kind: scope_kind,
match_ids: match_ids,
court: court,
on_date: on_date,
invited_by: invited_by,
note: note
).call
end
def initialize(tournament:, email:, scope_kind:, match_ids:, court:, on_date:, invited_by:, note: nil)
@tournament = tournament
@email = email.to_s.downcase.strip
@scope_kind = scope_kind.to_s
@match_ids = Array(match_ids).reject(&:blank?)
@court = court
@on_date = on_date
@invited_by = invited_by
@note = note.to_s.strip.presence
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
raise ArgumentError, "Email non valida" if @email.blank?
token = TournamentBroadcastInvitation.generate_token
invitation = @tournament.broadcast_invitations.create!(
email: @email,
token_digest: Digest::SHA256.hexdigest(token),
scope_kind: @scope_kind,
match_ids: @match_ids,
court: @court,
on_date: @on_date,
note: @note,
expires_at: 7.days.from_now
)
[invitation, token]
end
end
end
@@ -0,0 +1,95 @@
module Tournaments
class ProposeKnockout
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
first_round = @tournament.rounds.order(:position).first
return [] unless first_round
pairs = pair_qualified
slots = first_round.matches.order(:scheduled_at, :created_at).to_a
slots = create_slots!(first_round, [pairs.size, 1].max) if slots.empty?
updated = []
slots.each_with_index do |match, idx|
home, away = pairs[idx]
next if home.blank? && away.blank?
match.update!(home_participant: home, away_participant: away)
updated << match
end
updated.concat(seed_later_rounds!(slots))
updated
end
private
def pair_qualified
groups = @tournament.groups.order(:position).to_a
ranked = groups.map { |group| Tournaments::Standings.call(group).map(&:participant) }
return ranked.flatten.each_slice(2).to_a if groups.size < 2
pairs = []
first = ranked[0] || []
second = ranked[1] || []
pairs << [first[0], second[1]] if first[0] || second[1]
pairs << [second[0], first[1]] if second[0] || first[1]
leftover = (first.drop(2) + second.drop(2) + ranked.drop(2).flatten)
pairs.concat(leftover.each_slice(2).to_a)
pairs
end
def seed_later_rounds!(first_round_matches)
prev = first_round_matches
created = []
@tournament.rounds.order(:position).offset(1).each do |round|
needed = [prev.size / 2, 1].max
slots = round.matches.order(:scheduled_at, :created_at).to_a
last_at = prev.map(&:scheduled_at).compact.max
start = last_at&.+(1.hour)
slots = create_slots!(round, needed, start: start) if slots.empty?
slots.each_with_index do |match, idx|
home_src = prev[idx * 2]
away_src = prev[idx * 2 + 1]
attrs = {}
if home_src && match.home_source_match_id.blank?
attrs[:home_source_kind] = "winner_match"
attrs[:home_source_match_id] = home_src.id
end
if away_src && match.away_source_match_id.blank?
attrs[:away_source_kind] = "winner_match"
attrs[:away_source_match_id] = away_src.id
end
match.update!(attrs) if attrs.any?
created << match
end
prev = slots
end
created
end
def create_slots!(round, n, start: nil)
count = [n, 1].max
day = @tournament.ends_on
start ||= Time.zone.local(day.year, day.month, day.day, 18, 0, 0)
Array.new(count) do |i|
Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
tournament_round_id: round.id,
court: @tournament.court_list.first,
scheduled_at: start + i.hours
}
)
end
end
end
end
@@ -0,0 +1,58 @@
module Tournaments
class RecordResult
def self.call(match:, home_score:, away_score:, source: "manual", result_data: {}, walkover: nil)
new(
match: match,
home_score: home_score,
away_score: away_score,
source: source,
result_data: result_data,
walkover: walkover
).call
end
def initialize(match:, home_score:, away_score:, source:, result_data:, walkover:)
@match = match
@home_score = home_score
@away_score = away_score
@source = source
@result_data = result_data || {}
@walkover = walkover
end
def call
return @match unless @match.tournament_match?
status = "played"
winner_id = nil
if @walkover == "home"
status = "walkover_home"
winner_id = @match.home_participant_id
@home_score = @home_score.presence || 1
@away_score = 0
elsif @walkover == "away"
status = "walkover_away"
winner_id = @match.away_participant_id
@away_score = @away_score.presence || 1
@home_score = 0
else
winner_id = if @home_score.to_i > @away_score.to_i
@match.home_participant_id
elsif @away_score.to_i > @home_score.to_i
@match.away_participant_id
end
end
@match.update!(
home_score: @home_score,
away_score: @away_score,
result_status: status,
result_source: @source,
result_data: @result_data,
winner_participant_id: winner_id
)
Tournaments::FillKnockoutSources.call(@match.tournament)
@match
end
end
end
@@ -0,0 +1,20 @@
module Tournaments
class RevokeAssignment
def self.call(assignment:)
new(assignment).call
end
def initialize(assignment)
@assignment = assignment
end
def call
invitation = @assignment.invitation
@assignment.destroy!
return unless invitation
leftover = invitation.assignments.reload.pluck(:match_id).map(&:to_s)
invitation.update!(scope_kind: "matches", match_ids: leftover, court: nil, on_date: nil)
end
end
end
@@ -0,0 +1,34 @@
module Tournaments
class ScheduleMatch
def self.call(tournament:, attrs:)
new(tournament: tournament, attrs: attrs).call
end
def initialize(tournament:, attrs:)
@tournament = tournament
@attrs = attrs.to_h.symbolize_keys
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
raise Tournaments::EntitlementError.new("Torneo archiviato", code: "archived") unless @tournament.writable?
team = Tournaments::EnsureBroadcastTeam.call(@tournament)
match = team.matches.build(
tournament: @tournament,
sport_key: @tournament.sport_key,
home_participant_id: @attrs[:home_participant_id].presence,
away_participant_id: @attrs[:away_participant_id].presence,
tournament_group_id: @attrs[:tournament_group_id].presence,
tournament_round_id: @attrs[:tournament_round_id].presence,
court: @attrs[:court].presence,
location: @attrs[:location].presence,
scheduled_at: @attrs[:scheduled_at],
opponent_name: "TBD",
sets_to_win: @attrs[:sets_to_win].presence || 3
)
match.save!
match
end
end
end
@@ -0,0 +1,115 @@
module Tournaments
class Standings
Row = Struct.new(
:participant, :played, :won, :lost, :drawn, :points,
:sets_for, :sets_against, :score_for, :score_against,
keyword_init: true
)
def self.call(group)
new(group).call
end
def initialize(group)
@group = group
@tournament = group.tournament
end
def call
rows = @group.participants.with_attached_logo_file.map { |p| blank_row(p) }.index_by { |r| r.participant.id }
matches = @group.matches.where(result_status: %w[played walkover_home walkover_away])
matches.find_each do |match|
apply_match!(rows, match)
end
rows.values.sort_by { |row| [-row.points, -set_diff(row), -score_diff(row), row.participant.name] }
end
private
def blank_row(participant)
Row.new(
participant: participant, played: 0, won: 0, lost: 0, drawn: 0, points: 0,
sets_for: 0, sets_against: 0, score_for: 0, score_against: 0
)
end
def apply_match!(rows, match)
home = rows[match.home_participant_id]
away = rows[match.away_participant_id]
return unless home && away
hs = match.home_score.to_i
as = match.away_score.to_i
home.played += 1
away.played += 1
home.sets_for += hs
home.sets_against += as
away.sets_for += as
away.sets_against += hs
home.score_for += hs
home.score_against += as
away.score_for += as
away.score_against += hs
if hs == as
home.drawn += 1
away.drawn += 1
home.points += split_draw_points
away.points += split_draw_points
return
end
if hs > as
home.won += 1
away.lost += 1
home.points += win_points(hs, as)
away.points += loss_points(hs, as)
else
away.won += 1
home.lost += 1
away.points += win_points(as, hs)
home.points += loss_points(as, hs)
end
end
def settings
@settings ||= (@tournament.scoring_settings || {}).stringify_keys
end
def split_sets?
ActiveModel::Type::Boolean.new.cast(settings["split_sets"])
end
def win_points(winner_sets, loser_sets)
return settings.fetch("win_points", 3).to_i unless split_sets?
if winner_sets - loser_sets >= 2
settings.fetch("win_3_0_or_3_1", 3).to_i
else
settings.fetch("win_3_2", 2).to_i
end
end
def loss_points(winner_sets, loser_sets)
return settings.fetch("loss_points", 0).to_i unless split_sets?
if winner_sets - loser_sets >= 2
settings.fetch("loss_0_3_or_1_3", 0).to_i
else
settings.fetch("loss_2_3", 1).to_i
end
end
def split_draw_points
settings.fetch("draw_points", 1).to_i
end
def set_diff(row)
row.sets_for - row.sets_against
end
def score_diff(row)
row.score_for - row.score_against
end
end
end
@@ -0,0 +1,67 @@
module Tournaments
class SwapSides
class LiveBroadcastError < StandardError; end
def self.call(match:)
new(match).call
end
def initialize(match)
@match = match
end
def call
tournament = @match.tournament
raise ArgumentError, "not a tournament match" unless tournament
Tournaments::Entitlements.new(tournament.club).assert_writable!
unless tournament.writable?
raise Tournaments::EntitlementError.new(
I18n.t("flash.tournaments.archived_locked"),
code: "archived"
)
end
raise LiveBroadcastError, I18n.t("flash.matches.close_live_before_delete") unless @match.deletable?
home_id = @match.home_participant_id
away_id = @match.away_participant_id
home_score = @match.home_score
away_score = @match.away_score
home_source_kind = @match.home_source_kind
away_source_kind = @match.away_source_kind
home_source_match_id = @match.home_source_match_id
away_source_match_id = @match.away_source_match_id
home_source_group_id = @match.home_source_group_id
away_source_group_id = @match.away_source_group_id
home_source_rank = @match.home_source_rank
away_source_rank = @match.away_source_rank
@match.update!(
home_participant_id: away_id,
away_participant_id: home_id,
home_score: away_score,
away_score: home_score,
home_source_kind: away_source_kind,
away_source_kind: home_source_kind,
home_source_match_id: away_source_match_id,
away_source_match_id: home_source_match_id,
home_source_group_id: away_source_group_id,
away_source_group_id: home_source_group_id,
home_source_rank: away_source_rank,
away_source_rank: home_source_rank,
result_status: swapped_result_status
)
@match
end
private
def swapped_result_status
case @match.result_status
when "walkover_home" then "walkover_away"
when "walkover_away" then "walkover_home"
else @match.result_status
end
end
end
end
@@ -0,0 +1,57 @@
module Tournaments
class SyncAssignments
def self.call(invitation:)
new(invitation).sync_invitation!
end
def self.sync_match!(match)
return unless match.tournament_id.present?
match.tournament.broadcast_invitations.accepted.find_each do |invitation|
new(invitation).sync_invitation!
end
end
def initialize(invitation)
@invitation = invitation
end
def sync_invitation!
return unless @invitation.accepted_at.present?
return unless @invitation.accepted_by_id.present?
desired_ids = resolve_match_ids
existing = @invitation.assignments.index_by(&:match_id)
desired_ids.each do |match_id|
next if existing[match_id]
TournamentBroadcastAssignment.find_or_create_by!(
tournament_id: @invitation.tournament_id,
match_id: match_id,
user_id: @invitation.accepted_by_id
) do |row|
row.invitation = @invitation
end
end
stale = existing.keys - desired_ids
@invitation.assignments.where(match_id: stale).delete_all if stale.any?
end
private
def resolve_match_ids
matches = @invitation.tournament.matches
case @invitation.scope_kind
when "court_day"
matches
.where(court: @invitation.court)
.where("scheduled_at >= ? AND scheduled_at < ?", @invitation.on_date.beginning_of_day, @invitation.on_date.end_of_day)
.pluck(:id)
else
Array(@invitation.match_ids).map(&:to_s).intersection(matches.pluck(:id).map(&:to_s))
end
end
end
end