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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:00–07: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
|
||||
Reference in New Issue
Block a user