Compare commits

...
16 Commits
Author SHA1 Message Date
eminuxandCursor ce81bb5789 Nasconde dall'hub le partite con risultato già inserito.
Restano avviabili solo le gare in ritardo senza punteggio, non quelle già terminate dal portale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:13:10 +02:00
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00
eminuxandCursor c3ef8e91a6 Rende usabili calendario, overlay ospite e stato diretta del cartellone.
Il calendario non deve più scorrere in orizzontale, il logo a destra non copre il nome e una diretta YouTube o in collegamento compare come In diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:55:08 +02:00
eminuxandCursor b09e83db09 Allinea stato e titolo replay del tabellone al risultato reale.
Una gara con punteggio non resta più «In programma» solo perché l'orario è futuro, e i replay usano le squadre del match invece della squadra di trasmissione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:15:10 +02:00
eminux 097e55df79 Merge branch 'feature/tornei' into collaudo 2026-09-05 15:06:24 +02:00
eminuxandCursor a1f4c24a43 Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:05:57 +02:00
eminuxandCursor 7c7b2bf14c Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:48:50 +02:00
eminuxandCursor d52434fb2e Limita una diretta attiva per account e valorizza la copertina Premium Full.
Blocca la seconda diretta sullo stesso utente, registra gli abusi in admin e presenta la copertina come grafica caricata dalla società, con demo Team MLTV.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 12:52:26 +02:00
eminuxandCursor 356aa28fad Aggiunge la garanzia Soddisfatti o rimborsati di 30 giorni su prezzi, homepage e Termini.
Riduce il rischio percepito all'acquisto (Light e Full) e chiama l'archivio replay in modo orientato all'utente, senza cambiare prezzi o logica dei piani.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 19:27:28 +02:00
Emiliano FrascaroandCursor 1185d0ce61 Prepara iOS 2.0.12 build 35 per TestFlight e rafforza i test in produzione.
Forza dirette unlisted sul sito (non YouTube), copertina di test e verifica che in pausa gli spettatori vedano la slate. Alza il numero di build oltre le 33–34 già su App Store Connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 90d481a2d1 Rende più robusti i UI test iOS (logout, copertina, YouTube non selezionato in prod).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 50b8a8c8e2 Allinea iOS ad Android sul dialog Configura/Più tardi e porta l'app a 2.0.12.
Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
eminuxandCursor 79850dfc2c Non far crashare il reset password se SMTP rifiuta il destinatario.
Un account di test su dominio .test faceva 500; ora l'invio fallito viene loggato e l'API resta ok.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:00:22 +02:00
eminuxandCursor 645807b853 Evita 500 senza SMTP e chiude gli incidenti overflow stale.
Reset password e mail replay usano deliver_mail; il health check overflow risolve tutte le fingerprint del kind, non solo quella sana.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:55:36 +02:00
eminuxandCursor b08049cf69 Fix home 500: usa request.cookie_jar nella partial analytics suppress.
L'action cookies di PagesController mascherava il helper cookies nelle viste marketing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:31:05 +02:00
eminuxandCursor 29c8afb94d Admin analytics: heatmap per device, anteprima staff e analisi costi.
Separa heatmap mobile/desktop, opt-out analytics per operatori, dashboard costi con KPI e trend mensili.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:25:38 +02:00
250 changed files with 12107 additions and 333 deletions
+11 -7
View File
@@ -9,14 +9,18 @@ Con la crescita del numero di clienti (e delle dirette concorrenti attese), **al
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`): Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) | | Parametro | Ruolo (qualità-first 2026-09) |
|-----------|--------------------------------------| |-----------|-------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) | | `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (**4** — scale-out anticipato) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) | | `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (**6**; 8 solo dopo misure QoS) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) | | `STREAM_AUTOSCALE_MAX_NODES` | Quanti CPX in parallelo (fase A **12** → B 22 → C 33) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out | | `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` | Gate 24/7 (fase A **150**) |
| `STREAM_AUTOSCALE_QUIET_HOURS` | `02:00-07:00` Europe/Rome: no scale-out/warm-spare; sweeper chiude CPX idle |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster | | Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**). Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (fase A: 4+12×6 = **76**; fase C: 4+33×6 = **202**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo. Quiet hours: `Streams::NightCloudSweeper` + blocco provision in `Streams::Autoscaler`. Nodi cloud con sessioni attive di notte → alert Ops, non spegnere.
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo. Densità 8 solo dopo misure.
@@ -8,6 +8,7 @@ module Admin
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence
} }
@analytics_preview_active = analytics_preview_active?
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -41,15 +42,23 @@ module Admin
@page_path = params[:page_path].to_s @page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank? redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
from = parse_date(params[:from]) || 7.days.ago.to_date
to = parse_date(params[:to]) || Time.zone.today
@device_tab_stats = device_tab_stats(@page_path, from, to)
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: from,
to: parse_date(params[:to]) || Time.zone.today, to: to,
device: params[:device].presence, device: device,
layer: params[:layer].to_s layer: params[:layer].to_s
} }
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) cells = AnalyticsPageCell.where(
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@click_total = cells.sum(:click_count) @click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count) @move_total = cells.sum(:move_count)
@@ -67,8 +76,11 @@ module Admin
@max_weight = @cells.values.max.to_i @max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum @total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) stats = AnalyticsPageStat.where(
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@pageviews = stats.sum(:pageview_count) @pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples) @scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct) @scroll_sum = stats.sum(:scroll_sum_pct)
@@ -78,8 +90,30 @@ module Admin
@snapshot = find_snapshot(@page_path, @filters[:device]) @snapshot = find_snapshot(@page_path, @filters[:device])
end end
def preview_enable
Analytics::Suppress.enable!(cookies)
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
end
def preview_disable
Analytics::Suppress.disable!(cookies)
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
end
private private
def analytics_preview_active?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def preview_return_to(value)
path = value.to_s.strip
return root_path if path.blank?
return path if path.start_with?("/") && !path.start_with?("//")
admin_analytics_path
end
def parse_date(value) def parse_date(value)
return nil if value.blank? return nil if value.blank?
@@ -88,14 +122,43 @@ module Admin
nil nil
end end
def find_snapshot(page_path, device) def device_tab_stats(page_path, from, to)
scope = AnalyticsPageSnapshot.where(page_path: page_path) cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
if device.present? && AnalyticsEvent::DEVICES.include?(device) .group(:device)
snap = scope.find_by(device: device) .pluck(
return snap if snap&.image&.attached? :device,
Arel.sql("SUM(click_count)"),
Arel.sql("SUM(move_count)")
)
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
.group(:device)
.sum(:pageview_count)
AnalyticsEvent::DEVICES.index_with do |device|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
cells.merge(pageviews: pageview_totals[device].to_i)
end
end
def resolve_heatmap_device(tab_stats, requested)
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
return requested
end end
scope.order(captured_at: :desc).detect { |s| s.image.attached? } AnalyticsEvent::DEVICES.max_by do |device|
stats = tab_stats[device]
stats[:clicks] + stats[:moves] + stats[:pageviews]
end
end
def find_snapshot(page_path, device)
return nil unless AnalyticsEvent::DEVICES.include?(device)
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
.order(captured_at: :desc)
.detect { |snapshot| snapshot.image.attached? }
end end
end end
end end
@@ -12,6 +12,7 @@ module Admin
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@quote = @club.active_billing_quote @quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end end
def grant_comped def grant_comped
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module Admin
class CostEntriesController < Admin::BaseController
before_action :set_entry, only: %i[edit update destroy]
def new
@entry = PlatformCostEntry.new(
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
label: PlatformCostEntry::DEFAULT_LABEL
)
end
def create
@entry = PlatformCostEntry.new(entry_attributes)
if @entry.save
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @entry.update(entry_attributes)
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
month = @entry.month
@entry.destroy!
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
end
private
def set_entry
@entry = PlatformCostEntry.find(params[:id])
end
def entry_attributes
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
if attrs[:amount_euros].present?
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
end
attrs[:notes] = nil if attrs[:notes].blank?
attrs
end
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_param(date)
date.strftime("%Y-%m")
end
end
end
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Admin
class CostsController < Admin::BaseController
def index
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
analytics = Admin::CostAnalytics.new(month: @month)
@summary = analytics.summary
@trend = analytics.trend
@clubs = analytics.club_breakdown
@entries = PlatformCostEntry.for_month(@month).ordered
@month_options = month_options(@month)
end
private
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_options(selected)
start = selected - 23.months
(0..23).map { |i| start + i.months }.reverse
end
end
end
@@ -8,6 +8,8 @@ module Admin
.includes(:stream_node, match: :team) .includes(:stream_node, match: :team)
.order(started_at: :desc) .order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8) @teams = Team.includes(:matches).order(:name).limit(8)
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
end end
def metrics def metrics
@@ -18,6 +18,7 @@ module Admin
.find(params[:id]) .find(params[:id])
@events = @session.stream_events.recent.limit(100) @events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club @club = @session.match.team.club
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
end end
def stop def stop
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Admin
class StreamConcurrencyViolationsController < Admin::BaseController
def index
@filters = {
q: params[:q].to_s.strip.presence,
devices_differ: params[:devices_differ].to_s == "1"
}
scope = StreamConcurrencyViolation.recent
if @filters[:q]
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term",
term: term
)
end
scope = scope.two_devices if @filters[:devices_differ]
@total_count = scope.count
@violations = scope.limit(200)
@lookback_count = StreamConcurrencyViolation.lookback.count
@two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count
end
end
end
@@ -2,7 +2,13 @@
module Analytics module Analytics
class EventsController < ActionController::API class EventsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
end
payload = parse_payload payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
@@ -15,6 +21,10 @@ module Analytics
private private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def parse_payload def parse_payload
body = request.request_parameters body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array) return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
@@ -2,7 +2,13 @@
module Analytics module Analytics
class SnapshotsController < ActionController::API class SnapshotsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
end
result = Analytics::SnapshotIngest.new( result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path], path: params[:path] || params[:page_path],
device: params[:device], device: params[:device],
@@ -17,5 +23,11 @@ module Analytics
render json: { ok: true, skipped: result.skipped }, status: :accepted render json: { ok: true, skipped: result.skipped }, status: :accepted
end end
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
end end
end end
@@ -9,34 +9,23 @@ module Api
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
end end
render json: { render json: invitation_json(invitation).merge(valid: true)
valid: true,
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
end end
def accept def accept
invitation = find_pending_invitation invitation = find_pending_invitation
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation
if current_user.email.downcase != invitation.email.downcase email = invitation.email
if current_user.email.downcase != email.downcase
return render json: { return render json: {
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.", error: "Questo invito è per #{email}. Accedi con quell'indirizzo email.",
invited_email: invitation.email invited_email: email
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
invitation.accept!(current_user) invitation.accept!(current_user)
render json: { render json: invitation_json(invitation).merge(message: accept_message(invitation))
team_id: invitation.team_id,
team_name: invitation.team.name,
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
}
end end
private private
@@ -45,7 +34,42 @@ module Api
token = params[:token].to_s token = params[:token].to_s
return nil if token.blank? return nil if token.blank?
TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) digest = Digest::SHA256.hexdigest(token)
TeamInvitation.pending.find_by(token_digest: digest) ||
TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
end
def invitation_json(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
tournament = invitation.tournament
{
email: invitation.email,
tournament_id: tournament.id,
tournament_name: tournament.name,
club_name: tournament.club.name,
team_id: tournament.broadcast_team_id,
team_name: tournament.name,
staff_kind: "transmission",
expires_at: invitation.expires_at
}
else
{
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
end
end
def accept_message(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
"Sei incaricato delle dirette per #{invitation.tournament.name}."
else
"Sei entrato in #{invitation.team.name} come responsabile trasmissione."
end
end end
end end
end end
@@ -10,13 +10,22 @@ module Api
def index def index
matches = @team.matches matches = @team.matches
.includes(:team, :stream_sessions) .includes(:team, :stream_sessions, :home_participant, :away_participant, :tournament)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?) unless current_user.club_admin?(@team.club)
render json: matches.map { |m| match_json(m) } if @team.tournament_broadcast?
assigned_ids = current_user.tournament_broadcast_assignments.select(:match_id)
matches = matches.where(id: assigned_ids)
end
end
render json: matches.select(&:coach_hub_visible?).map { |m| match_json(m) }
end end
def create def create
if @team.tournament_broadcast?
return render json: { error: "Le partite del torneo si programmano dal sito web." }, status: :unprocessable_entity
end
attrs = match_params.to_h attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs) normalize_scoring_rules!(attrs)
@@ -112,9 +121,12 @@ module Api
{ {
id: match.id, id: match.id,
team_id: match.team_id, team_id: match.team_id,
team_name: team.name, team_name: match.home_display_name,
opponent_name: match.opponent_name, opponent_name: match.away_display_name,
location: match.location, location: match.court_or_location,
court: match.court,
tournament_id: match.tournament_id,
tournament_name: match.tournament&.name,
scheduled_at: match.scheduled_at, scheduled_at: match.scheduled_at,
sport: match.sport_key, sport: match.sport_key,
sport_key: match.sport_key, sport_key: match.sport_key,
@@ -126,11 +138,11 @@ module Api
scoring_rules: match.scoring_rules.presence, scoring_rules: match.scoring_rules.presence,
effective_scoring_rules: match.effective_scoring_rules, effective_scoring_rules: match.effective_scoring_rules,
category: match.category, category: match.category,
home_primary_color: team.effective_primary_color, home_primary_color: match.home_participant&.effective_primary_color || team.effective_primary_color,
home_secondary_color: team.effective_secondary_color, home_secondary_color: match.home_participant&.effective_secondary_color || team.effective_secondary_color,
home_logo_url: api_absolute_url(team.effective_logo_url), home_logo_url: api_absolute_url(match.home_participant&.effective_logo_url || team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color, opponent_primary_color: match.away_participant&.effective_primary_color || match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url), opponent_logo_url: api_absolute_url(match.away_participant&.effective_logo_url || match.opponent_logo_url),
**match_cover_json(match), **match_cover_json(match),
active_session_id: active&.id, active_session_id: active&.id,
active_session_status: active&.status, active_session_status: active&.status,
@@ -6,6 +6,10 @@ module Api
def create def create
team_ids = current_user.streamable_teams.map(&:id) team_ids = current_user.streamable_teams.map(&:id)
match = Match.where(team_id: team_ids).find(params[:match_id]) match = Match.where(team_id: team_ids).find(params[:match_id])
unless current_user.can_broadcast_match?(match)
return render json: { error: "Non sei incaricato di trasmettere questa partita" }, status: :forbidden
end
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created render json: session_json(session), status: :created
end end
@@ -110,6 +110,9 @@ module Api
secondary_color: team.effective_secondary_color, secondary_color: team.effective_secondary_color,
club_id: team.club_id, club_id: team.club_id,
club_name: team.club.name, club_name: team.club.name,
tournament_broadcast: team.tournament_broadcast?,
tournament_id: team.broadcast_tournament&.id,
tournament_name: team.broadcast_tournament&.name,
youtube_connected: yt.connected?, youtube_connected: yt.connected?,
youtube_selectable: yt.selectable?, youtube_selectable: yt.selectable?,
youtube_channel_title: yt.channel_title, youtube_channel_title: yt.channel_title,
@@ -56,9 +56,9 @@ module Public
def show def show
require_club_owner!(@club) require_club_owner!(@club)
apply_checkout_flash! apply_checkout_flash!
@entitlements_team = @club.teams.first @entitlements_team = @club.teams.visible.first
@entitlements = @entitlements_team&.entitlements @entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name) @teams = @club.teams.visible.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings? @replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end end
@@ -2,26 +2,40 @@ module Public
class InvitationsController < WebBaseController class InvitationsController < WebBaseController
def show def show
@token = params[:token] @token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s)) digest = Digest::SHA256.hexdigest(@token.to_s)
unless @invitation @invitation = TeamInvitation.pending.find_by(token_digest: digest)
@tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless @invitation
unless @invitation || @tournament_invitation
redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired") redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
end end
end end
def accept def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s)) token = params[:token].to_s
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless invitation
if logged_in? unless invitation || tournament_invitation
if current_user.email.downcase != invitation.email.downcase return redirect_to public_pricing_path, alert: t("flash.invitations.invalid")
redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email) end
return
end target_email = (invitation || tournament_invitation).email
unless logged_in?
session[:pending_invite_token] = token
return redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: target_email)
end
if current_user.email.downcase != target_email.downcase
return redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: target_email)
end
if invitation
invitation.accept!(current_user) invitation.accept!(current_user)
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team") redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else else
session[:pending_invite_token] = params[:token] tournament_invitation.accept!(current_user)
redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email) redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end end
end end
end end
@@ -33,7 +33,11 @@ module Public
.order(scheduled_at: :asc) .order(scheduled_at: :asc)
.limit(50) .limit(50)
@online_paths = Mediamtx::Client.new.online_path_names tournaments = Tournament.listed_on_live.with_attached_logo_file.includes(:club).search_public(@query)
tournaments = tournaments.where(club_id: @club.id) if @club
@public_tournaments = tournaments.order(starts_on: :desc, name: :asc).limit(12)
@online_paths = fetch_online_paths
return unless logged_in? && @club return unless logged_in? && @club
@@ -83,5 +87,13 @@ module Public
score: session.score_state&.as_cable_payload score: session.score_state&.as_cable_payload
} }
end end
private
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end end
end end
@@ -17,11 +17,18 @@ module Public
session[:user_id] = @user.id session[:user_id] = @user.id
if session[:pending_invite_token].present? if session[:pending_invite_token].present?
token = session.delete(:pending_invite_token) token = session.delete(:pending_invite_token)
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
if invitation && invitation.email.downcase == @user.email.downcase if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user) invitation.accept!(@user)
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team") return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
end end
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
if tournament_invitation && tournament_invitation.email.downcase == @user.email.downcase
tournament_invitation.accept!(@user)
return redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end
end end
redirect_to public_new_club_path, notice: t("flash.registrations.account_created") redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else else
@@ -45,6 +45,8 @@ module Public
PRIVATE_WEB_CONTROLLERS = %w[ PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members club_matches matches team_roster_members
tournaments tournament_participants tournament_groups
tournament_matches tournament_invitations tournament_results
].freeze ].freeze
def load_site_announcements def load_site_announcements
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" }, { loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" }, { loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" }, { loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/tornei", changefreq: "daily", priority: "0.8" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
@@ -25,6 +26,14 @@ module Public
} }
end end
Tournament.visible_to_public.find_each do |tournament|
@entries << {
loc: "#{base}/tornei/#{tournament.slug}",
changefreq: "daily",
priority: "0.75"
}
end
respond_to do |format| respond_to do |format|
format.xml { render layout: false } format.xml { render layout: false }
end end
@@ -27,6 +27,11 @@ module Public
end end
def details def details
if @team.tournament_broadcast? && @team.broadcast_tournament
redirect_to public_tournament_path(@team.broadcast_tournament)
return
end
load_team_details! load_team_details!
render :details render :details
end end
@@ -0,0 +1,29 @@
module Public
class TournamentAssignmentsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
unless @tournament.writable?
redirect_to public_tournament_path(@tournament, tab: "dirette"),
alert: t("flash.tournaments.archived_locked")
return
end
assignment = @tournament.broadcast_assignments.find(params[:assignment_id])
Tournaments::RevokeAssignment.call(assignment: assignment)
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.assignment_revoked")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentGroupsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
position = @tournament.groups.maximum(:position).to_i + 1
name = params.dig(:tournament_group, :name).presence || "Girone #{('A'.ord + position).chr}"
@tournament.groups.create!(name: name, position: position)
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.groups.find(params[:group_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,136 @@
module Public
class TournamentInvitationsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
if params[:intent] == "save"
save_draft
return
end
match_ids = Array(params[:match_ids]).reject(&:blank?)
scope_kind = if match_ids.any?
"matches"
elsif params[:whole_court].present? || params[:scope_kind].to_s == "court_day"
"court_day"
else
raise ArgumentError, t("tournaments.hub.stream_plan_need_selection")
end
persist_draft! unless calendar_invite?
invitation, token = Tournaments::Invite.call(
tournament: @tournament,
email: params[:email],
scope_kind: scope_kind,
match_ids: match_ids,
court: params[:court],
on_date: params[:on_date].presence,
invited_by: current_user,
note: params[:note]
)
invite_url = public_invitation_url(token: token)
flash[:invite_url] = invite_url unless calendar_invite?
body_html = Tournaments::ComposeInviteEmail.call(
html: draft_html,
invite_url: invite_url,
expires_on: I18n.l(invitation.expires_at.to_date, format: :long)
)
invitation.update!(email_html: body_html)
begin
Tournaments::InvitationMailer.transmission_invite(
tournament: @tournament,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user,
body_html: body_html
).deliver_now
flash[:notice] = t("flash.tournaments.invite_email_sent", email: invitation.email)
rescue StandardError => e
Rails.logger.error("[tournament_invite] #{e.class}: #{e.message}")
flash[:alert] = t("flash.tournaments.invite_email_failed", email: invitation.email)
end
redirect_to public_tournament_path(@tournament, tab: invite_return_tab, anchor: calendar_invite? ? nil : "invito-generato")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid, ArgumentError => e
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), alert: e.message
end
def save_draft
Tournaments::Entitlements.new(@club).assert_writable!
persist_draft!
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.invite_draft_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "dirette"), alert: e.message
end
def upload_image
Tournaments::Entitlements.new(@club).assert_writable!
file = params[:file]
unless file.respond_to?(:content_type) && file.content_type.to_s.in?(%w[image/png image/jpeg image/webp image/gif])
return render json: { error: t("tournaments.hub.invite_image_invalid") }, status: :unprocessable_entity
end
if file.size > 2.megabytes
return render json: { error: t("tournaments.hub.invite_image_too_big") }, status: :unprocessable_entity
end
@tournament.invite_email_images.attach(file)
blob = @tournament.invite_email_images.blobs.last
render json: { url: url_for(blob) }
rescue Tournaments::EntitlementError => e
render json: { error: e.message }, status: :forbidden
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
invitation = @tournament.broadcast_invitations.find(params[:invitation_id])
invitation.destroy!
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), notice: t("flash.tournaments.invite_canceled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def persist_draft!
@tournament.update!(
invite_draft_html: Tournaments::ComposeInviteEmail.sanitize_html(draft_html),
invite_draft_note: params[:note].to_s.strip.presence,
invite_draft_email: params[:email].to_s.strip.presence,
invite_draft_saved_at: Time.current
)
end
def draft_html
html = params[:email_html].to_s
stripped = ActionController::Base.helpers.strip_tags(html).to_s.gsub(/\s+/, "")
return html if stripped.present?
stored = @tournament.invite_draft_html.to_s
if ActionController::Base.helpers.strip_tags(stored).to_s.gsub(/\s+/, "").present?
return stored
end
Tournaments::ComposeInviteEmail.default_html(tournament: @tournament, invited_by: current_user)
end
def calendar_invite?
params[:from].to_s == "calendar" || invite_return_tab == "calendario"
end
def invite_return_tab
tab = params[:tab].to_s
tab = "dirette" if tab == "delega"
tab.presence_in(%w[calendario dirette]) || "dirette"
end
end
end
@@ -0,0 +1,113 @@
module Public
class TournamentMatchesController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::ScheduleMatch.call(tournament: @tournament, attrs: match_params)
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_scheduled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
attrs = match_params
if match.result_recorded?
attrs = attrs.except(:home_participant_id, :away_participant_id)
end
match.update!(attrs)
schedule_changed = match.previous_changes.keys.intersect?(%w[scheduled_at court location])
recorded = record_result_if_present!(match)
notice = if recorded && !schedule_changed
t("flash.tournaments.result_saved")
else
t("flash.tournaments.match_updated")
end
redirect_to public_tournament_path(@tournament, tab: hub_tab), notice: notice
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def swap
match = @tournament.matches.find(params[:match_id])
Tournaments::SwapSides.call(match: match)
respond_to do |format|
format.json { render json: { ok: true, matchup_label: match.reload.matchup_label } }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.sides_swapped") }
end
rescue Tournaments::EntitlementError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_club_billing_path(@club), alert: e.message }
end
rescue Tournaments::SwapSides::LiveBroadcastError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message }
end
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
unless match.deletable?
redirect_to public_tournament_path(@tournament, tab: "calendario"),
alert: t("flash.matches.close_live_before_delete")
return
end
match.destroy!
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_deleted")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def record_result_if_present!(match)
home = params[:home_score]
away = params[:away_score]
return false if home.blank? || away.blank?
Tournaments::RecordResult.call(match: match, home_score: home, away_score: away, source: "manual")
true
end
def match_params
p = params.require(:match).permit(
:home_participant_id, :away_participant_id, :tournament_group_id,
:tournament_round_id, :court, :location, :scheduled_at
)
%i[home_participant_id away_participant_id].each do |key|
p[key] = p[key].presence if p.key?(key)
end
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
p
end
def parse_scheduled_at(value)
raw = value.to_s.strip
return nil if raw.blank?
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
rescue ArgumentError
Time.zone.parse(raw)
end
def hub_tab
params[:tab].to_s.presence_in(%w[squadre struttura calendario dirette tabellone]) || "calendario"
end
end
end
@@ -0,0 +1,81 @@
module Public
class TournamentPagesController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def index
@tournaments = Tournament
.visible_to_public
.with_attached_logo_file
.includes(:club)
.order(starts_on: :desc, name: :asc)
end
def show
@tournament = Tournament.find_by!(slug: params[:slug])
unless @tournament.published? || owner_access?
raise ActiveRecord::RecordNotFound
end
@club = @tournament.club
@owner_preview = !@tournament.published? && owner_access?
@owner_manage = owner_access?
@tab = params[:tab].to_s.presence_in(%w[risultati tabellone]) || "risultati"
load_public_content!
end
private
def owner_access?
logged_in? && @tournament.club.owned_by?(current_user)
end
def load_public_content!
@online_paths = fetch_online_paths
@matches = @tournament.matches
.includes(
:tournament_group,
:tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob }
)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
broadcasting = StreamSession
.broadcasting
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id))
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
.to_a
@live_by_match_id = {}
broadcasting.each { |session| @live_by_match_id[session.match_id] ||= session }
@live_sessions = broadcasting.select(&:public_watchable?)
@recordings = Recording.ready.publicly_listed
.joins(stream_session: :match)
.where(matches: { tournament_id: @tournament.id })
.includes(stream_session: { match: [:home_participant, :away_participant] })
.order(recorded_at: :desc)
@recording_by_match_id = {}
@recordings.each do |rec|
match_id = rec.stream_session.match_id
@recording_by_match_id[match_id] ||= rec
end
@groups = @tournament.groups.order(:position)
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@rounds = @tournament.rounds.order(:position)
@matches_by_round = @matches.select { |match| match.tournament_round_id.present? }
.group_by(&:tournament_round_id)
end
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -0,0 +1,51 @@
module Public
class TournamentParticipantsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.create!(participant_params)
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
participant = @tournament.participants.find(params[:participant_id])
participant.update!(participant_params)
file = params.dig(:tournament_participant, :logo_file)
participant.logo_file.attach(file) if file.present?
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_updated")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.find(params[:participant_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def participant_params
permitted = params.require(:tournament_participant).permit(:name, :group_id, :primary_color, :logo_url, :source_team_id)
permitted[:group_id] = permitted[:group_id].presence if permitted.key?(:group_id)
permitted
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentResultsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
Tournaments::RecordResult.call(
match: match,
home_score: params[:home_score],
away_score: params[:away_score],
source: "manual",
walkover: params[:walkover].presence
)
redirect_to public_tournament_path(@tournament, tab: params[:tab].presence || "calendario"),
notice: t("flash.tournaments.result_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,190 @@
module Public
class TournamentsController < WebBaseController
before_action :require_login!
before_action :set_club, only: %i[index new create]
before_action :set_tournament, except: %i[index new create]
before_action :require_owner!
before_action :require_writable!, only: %i[update generate_group_matches propose_knockout]
def index
@tournaments = @club.tournaments.order(starts_on: :desc)
@can_create = Tournaments::Entitlements.new(@club).premium_full?
end
def new
assert_full!
return if performed?
@tournament = @club.tournaments.build(
sport_key: Sports::Catalog.normalize_key(@club.sport),
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "mixed",
courts: ["Campo 1", "Campo 2"],
knockout_size: 4
)
end
def create
@tournament = Tournaments::Create.call(club: @club, attrs: tournament_params)
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.created")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
@tournament = e.record
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def show
if params[:tab] == "delega"
redirect_to public_tournament_path(@tournament, tab: "dirette")
return
end
load_hub!
end
def update
attrs = tournament_params
file = attrs.delete(:logo_file)
@tournament.assign_attributes(attrs)
@tournament.logo_file.attach(file) if file.present?
@tournament.save!
redirect_to public_tournament_path(@tournament, tab: hub_tab || params[:tab]), notice: t("flash.tournaments.updated")
rescue ActiveRecord::RecordInvalid => e
load_hub!
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :show, status: :unprocessable_entity
end
def publish
assert_full!
return if performed?
@tournament.update!(status: "published")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.published")
end
def unpublish
assert_full!
return if performed?
@tournament.update!(status: "draft")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.unpublished")
end
def archive
assert_full!
return if performed?
@tournament.update!(status: "archived")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.archived")
end
def destroy
Tournaments::Destroy.call(tournament: @tournament)
redirect_to public_club_tournaments_path(@club), notice: t("flash.tournaments.deleted")
rescue Tournaments::Destroy::LiveBroadcastError => e
redirect_to public_tournament_path(@tournament), alert: e.message
end
def generate_group_matches
created = Tournaments::GenerateGroupMatches.call(tournament: @tournament)
redirect_to public_tournament_path(@tournament, tab: "calendario"),
notice: t("flash.tournaments.group_matches_created", count: created.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message
end
def propose_knockout
updated = Tournaments::ProposeKnockout.call(@tournament)
redirect_to public_tournament_path(@tournament, tab: "tabellone"),
notice: t("flash.tournaments.knockout_proposed", count: updated.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "tabellone"), alert: e.message
end
private
def set_club
@club = Club.find(params[:club_id] || params[:id])
end
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
end
def require_owner!
require_club_owner!(@club)
end
def assert_full!
Tournaments::Entitlements.new(@club).assert_writable!
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def require_writable!
assert_full!
return if performed?
return if @tournament.writable?
redirect_to public_tournament_path(@tournament), alert: t("flash.tournaments.archived_locked")
end
def load_hub!
@tab = hub_tab || "squadre"
@participants = @tournament.participants.with_attached_logo_file.includes(:group, :source_team).order(:position, :name)
@groups = @tournament.groups.order(:position)
@rounds = @tournament.rounds.order(:position)
@matches = @tournament.matches.includes(
{ home_participant: { logo_file_attachment: :blob } },
{ away_participant: { logo_file_attachment: :blob } },
:tournament_group, :tournament_round, :stream_sessions,
{ broadcast_assignments: :user }
).order(:scheduled_at)
@invitations = @tournament.broadcast_invitations.order(created_at: :desc)
@pending_invites_by_match_id = pending_invites_by_match_id
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@overlap_warnings = overlap_warnings
@writable = @tournament.writable? && Tournaments::Entitlements.new(@club).premium_full?
end
def hub_tab
raw = params[:tab].to_s
raw = "dirette" if raw == "delega"
raw.presence_in(%w[squadre struttura calendario dirette tabellone])
end
def pending_invites_by_match_id
pending = @tournament.broadcast_invitations.pending.to_a
map = Hash.new { |h, k| h[k] = [] }
@matches.each do |match|
pending.each do |invitation|
map[match.id] << invitation if invitation.covers_match?(match)
end
end
map
end
def overlap_warnings
limit = @tournament.concurrent_limit
return [] unless limit
@matches.group_by { |m| m.scheduled_at&.strftime("%Y-%m-%d %H:%M") }.filter_map do |slot, list|
next if slot.blank? || list.size <= limit
t("tournaments.hub.overlap_warning", slot: slot, count: list.size, limit: limit)
end
end
def tournament_params
params.require(:tournament).permit(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts, :logo_file
)
end
end
end
+21
View File
@@ -1,4 +1,21 @@
module AdminHelper module AdminHelper
def format_euros(cents, precision: 2)
return I18n.t("admin.common.dash") if cents.nil?
format("%.*f €", precision, cents.to_f / 100.0)
end
def format_hours(hours)
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
total_minutes = (hours.to_f * 60).round
format_duration_minutes(total_minutes)
end
def admin_month_label(month)
I18n.l(month, format: "%B %Y")
end
def format_bytes(bytes) def format_bytes(bytes)
return "" if bytes.nil? return "" if bytes.nil?
@@ -171,4 +188,8 @@ module AdminHelper
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") } labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash") labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end end
def admin_concurrency_violation_lookback_count
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
end
end end
+4
View File
@@ -3,6 +3,10 @@ module LegalHelper
"20 agosto 2026" "20 agosto 2026"
end end
def terms_last_updated
"31 agosto 2026"
end
def cookie_policy_last_updated def cookie_policy_last_updated
"3 giugno 2026" "3 giugno 2026"
end end
+24 -1
View File
@@ -53,6 +53,18 @@ module Public
def live_match_card_heading(match, link_team: true) def live_match_card_heading(match, link_team: true)
team = match.team team = match.team
club_name = team.club&.name.presence || t("score.default_club_name") club_name = team.club&.name.presence || t("score.default_club_name")
if match.tournament_match?
tournament = match.tournament
heading_name = tournament&.name.presence || club_name
matchup = match.matchup_label
return content_tag(:h3, class: "live-card__title") do
safe_join([
content_tag(:span, heading_name, class: "live-card__club"),
content_tag(:span, matchup, class: "live-card__matchup")
])
end
end
team_slug = team.respond_to?(:slug) ? team.slug : nil team_slug = team.respond_to?(:slug) ? team.slug : nil
team_label = if link_team && team_slug.present? team_label = if link_team && team_slug.present?
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link") link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
@@ -68,6 +80,17 @@ module Public
end end
def live_match_page_heading(match) def live_match_page_heading(match)
if match.tournament_match?
tournament = match.tournament
club_name = tournament&.name.presence || match.team.club&.name.presence || t("score.default_club_name")
return content_tag(:div, class: "live-page-heading") do
safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"),
content_tag(:h1, match.matchup_label, class: "live-page-heading__matchup")
])
end
end
team = match.team team = match.team
club_name = team.club&.name.presence || t("score.default_club_name") club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do content_tag(:div, class: "live-page-heading") do
@@ -91,7 +114,7 @@ module Public
else score_state.away_points else score_state.away_points
end end
t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name) t("score.points_label", team: match.home_display_name, home: home, away: away, opponent: match.away_display_name)
end end
private private
@@ -0,0 +1,175 @@
module Public
module TournamentsHelper
def tournament_team_chip(participant, name: nil)
label = name.presence || participant&.name.presence || t("tournaments.tbd")
logo = participant&.effective_logo_url
content_tag(:span, class: "tournament-team-chip") do
parts = []
if logo.present?
parts << image_tag(logo, alt: "", width: 28, height: 28)
end
parts << content_tag(:span, label)
safe_join(parts)
end
end
def tournament_datetime_local(time)
time&.in_time_zone&.strftime("%Y-%m-%dT%H:%M")
end
def tournament_invite_default_html(tournament, invited_by)
Tournaments::ComposeInviteEmail.default_html(tournament: tournament, invited_by: invited_by)
end
def tournament_invite_editor_html(tournament, invited_by)
stored = tournament.invite_draft_html.to_s
return tournament_invite_default_html(tournament, invited_by) if stored.blank?
Tournaments::ComposeInviteEmail.sanitize_html(stored)
end
def tournament_streaming_operator_label(user)
user&.name.presence || user&.email.presence || ""
end
def tournament_pending_invites_for(match)
Array(@pending_invites_by_match_id&.[](match.id))
end
def tournament_invite_cancel_confirm(invitation)
count = @matches.to_a.count { |match| invitation.covers_match?(match) }
if count > 1
t("tournaments.hub.streaming_cancel_multi", email: invitation.email, count: count)
else
t("tournaments.hub.streaming_cancel", email: invitation.email)
end
end
def tournament_matches_grouped_by_date(matches)
matches.group_by { |match| match.scheduled_at&.in_time_zone&.to_date }
end
def tournament_streaming_coverage(matches)
assigned = pending = open = 0
matches.each do |match|
if match.broadcast_assignments.any?
assigned += 1
elsif tournament_pending_invites_for(match).any?
pending += 1
else
open += 1
end
end
{ assigned: assigned, pending: pending, open: open, total: matches.size }
end
def tournament_broadcast_live_state(match)
sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at)
if latest && !latest.status.in?(%w[ended error])
return :live if latest.status.in?(%w[live reconnecting paused connecting])
return :waiting_operator
end
return :ended if sessions.any? { |session| session.status.in?(%w[ended error]) } || match.played?
if match.broadcast_assignments.any?
return :waiting_time if match.scheduled_upcoming?
return :waiting_operator
end
return :waiting_operator if tournament_pending_invites_for(match).any?
:uncovered
end
def tournament_streaming_live_counts(matches)
counts = Hash.new(0)
matches.each { |match| counts[tournament_broadcast_live_state(match)] += 1 }
counts
end
def tournament_public_live_session(match)
@live_by_match_id&.[](match.id)
end
def tournament_public_recording(match)
@recording_by_match_id&.[](match.id)
end
def tournament_public_board_state(match)
return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming?
:waiting
end
def tournament_public_watch_target(session)
return unless session
if session.matchlivetv_platform? && session.publicly_listed?
return [public_live_path(session), t("tournaments.page.watch_live"), {}]
end
if session.youtube_watch_url.present?
return [session.youtube_watch_url, t("live.show.youtube_watch_link"), { target: "_blank", rel: "noopener" }]
end
nil
end
def tournament_public_score_label(match)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
session = tournament_public_live_session(match)
score = session&.score_state
return unless score
"#{score.home_points}#{score.away_points}"
end
def tournament_match_sides_locked?(match)
match.result_recorded?
end
def tournament_phase_label(match)
match.tournament_round&.name.presence || match.tournament_group&.name.presence || ""
end
def tournament_side_blank_label(match, side)
source = tournament_side_source_label(match, side)
source.presence || t("tournaments.tbd")
end
def tournament_side_source_label(match, side)
kind = match.public_send("#{side}_source_kind").to_s
case kind
when "winner_match"
source = tournament_source_match(match, side)
return t("tournaments.hub.winner_tbd") if source.blank?
t("tournaments.hub.winner_of", match: source.matchup_label)
when "group_rank"
group = tournament_source_group(match, side)
rank = match.public_send("#{side}_source_rank").to_i
return t("tournaments.hub.group_rank_tbd") if group.blank? || rank <= 0
t("tournaments.hub.group_rank", group: group.name, rank: rank)
end
end
def tournament_source_match(match, side)
id = match.public_send("#{side}_source_match_id")
return if id.blank?
@matches&.find { |item| item.id == id } || Match.find_by(id: id)
end
def tournament_source_group(match, side)
id = match.public_send("#{side}_source_group_id")
return if id.blank?
@groups&.find { |item| item.id == id } || TournamentGroup.find_by(id: id)
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class NightCloudSweeperJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_NIGHT_SWEEP_INTERVAL_SECS", "900").to_i
REDIS_CHAIN_KEY = "streams:night_sweep:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::NightCloudSweeper.sweep!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
@@ -0,0 +1,27 @@
module Tournaments
class InvitationMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def transmission_invite(tournament:, invitation:, invite_url:, invited_by:, body_html: nil)
@tournament = tournament
@club = tournament.club
@invitation = invitation
@invite_url = invite_url
@invited_by = invited_by
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
@body_html = body_html.presence || invitation.email_html.presence
@body_text = ActionController::Base.helpers.strip_tags(@body_html.to_s).squish
I18n.with_locale(I18n.locale) do
mail(
to: invitation.email,
subject: t(
"mailers.tournament_invite.subject",
tournament: @tournament.name,
club: @club.name
)
)
end
end
end
end
+1
View File
@@ -6,6 +6,7 @@ class Club < ApplicationRecord
has_many :club_memberships, dependent: :destroy has_many :club_memberships, dependent: :destroy
has_many :users, through: :club_memberships has_many :users, through: :club_memberships
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :tournaments, dependent: :restrict_with_error
has_one :youtube_credential, dependent: :destroy has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy has_one :subscription, dependent: :destroy
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
+70 -2
View File
@@ -2,7 +2,14 @@ class Match < ApplicationRecord
include Coverable include Coverable
belongs_to :team belongs_to :team
belongs_to :tournament, optional: true
belongs_to :home_participant, class_name: "TournamentParticipant", optional: true
belongs_to :away_participant, class_name: "TournamentParticipant", optional: true
belongs_to :tournament_group, optional: true
belongs_to :tournament_round, optional: true
belongs_to :winner_participant, class_name: "TournamentParticipant", optional: true
has_many :stream_sessions, dependent: :destroy has_many :stream_sessions, dependent: :destroy
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_one_attached :opponent_logo_file has_one_attached :opponent_logo_file
@@ -20,6 +27,8 @@ class Match < ApplicationRecord
before_validation :normalize_sport_key before_validation :normalize_sport_key
before_validation :inherit_sport_from_team, on: :create before_validation :inherit_sport_from_team, on: :create
before_validation :sync_tournament_display_names
after_commit :sync_tournament_broadcast_assignments, on: %i[create update]
scope :scheduled_for_live, -> { scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now) where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
@@ -85,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible? def coach_hub_visible?
active = active_stream_session active = active_stream_session
return true if active&.resumable? return true if active&.resumable?
return true if active&.idle? return true if active&.idle?
return false if result_recorded?
return false if stream_completed? return false if stream_completed?
return true if scheduled_on_calendar?
return true if tournament_open_for_late_stream?
scheduled_upcoming? false
end
def tournament_open_for_late_stream?
return false unless tournament_match?
event = tournament
event.present? && !event.archived? && event.ends_on >= Date.current
end end
def effective_board_type def effective_board_type
@@ -116,6 +138,30 @@ class Match < ApplicationRecord
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end end
def tournament_match?
has_attribute?(:tournament_id) && tournament_id.present?
end
def home_display_name
home_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : team.name)
end
def away_display_name
away_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : opponent_name.presence)
end
def matchup_label
"#{home_display_name} vs #{away_display_name}"
end
def played?
result_status == "played"
end
def court_or_location
court.presence || location
end
# Alias legacy API/spec (colonna rinominata in sport_key). # Alias legacy API/spec (colonna rinominata in sport_key).
def sport def sport
sport_key sport_key
@@ -132,12 +178,34 @@ class Match < ApplicationRecord
end end
def inherit_sport_from_team def inherit_sport_from_team
if has_attribute?(:tournament_id) && tournament.present?
self.sport_key = tournament.sport_key if new_record? || sport_key.blank?
return
end
return unless team.present? return unless team.present?
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione. # Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
self.sport_key = team.sport_key if new_record? || sport_key.blank? self.sport_key = team.sport_key if new_record? || sport_key.blank?
end end
def sync_tournament_display_names
return unless has_attribute?(:tournament_id) && tournament_id.present?
self.opponent_name = away_display_name if away_participant.present? || opponent_name.blank?
venue_court = [tournament&.venue, court].compact_blank
self.location = venue_court.join("") if venue_court.any?
self.category = tournament.name if category.blank? && tournament.present?
end
def sync_tournament_broadcast_assignments
return unless has_attribute?(:tournament_id) && tournament_id.present?
return unless saved_change_to_court? || saved_change_to_scheduled_at? || previously_new_record?
Tournaments::SyncAssignments.sync_match!(self)
end
def sport_key_known def sport_key_known
return if sport_key.blank? return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key) return if Sports::Catalog.find_optional(sport_key)
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class PlatformCostEntry < ApplicationRecord
DEFAULT_LABEL = "Piattaforma produzione"
validates :month, presence: true
validates :label, presence: true, length: { maximum: 120 }
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
before_validation :normalize_month
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
scope :ordered, -> { order(month: :desc, created_at: :desc) }
def month=(value)
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
super(Date.strptime(value, "%Y-%m"))
else
super(value)
end
end
def amount_euros
amount_cents.to_f / 100.0
end
private
def normalize_month
return if month.blank?
parsed =
case month
when Date
month
when Time, ActiveSupport::TimeWithZone
month.to_date
when String
if month.match?(/\A\d{4}-\d{2}\z/)
Date.strptime(month, "%Y-%m")
else
Date.parse(month)
end
else
Date.parse(month.to_s)
end
self.month = parsed.beginning_of_month
rescue ArgumentError, TypeError
errors.add(:month, :invalid)
end
end
+22 -2
View File
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
end end
def title_or_default def title_or_default
title.presence || default_title match = stream_session&.match
return title.presence || "Replay" unless match
generated = match.matchup_label
stored = title.to_s.strip
return generated if stored.blank?
return generated if stale_auto_title?(match, stored)
stored
end end
def default_title def default_title
match = stream_session&.match match = stream_session&.match
return "Replay" unless match return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label
end end
def recorded_at_or_fallback def recorded_at_or_fallback
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
private private
def stale_auto_title?(match, stored)
return false unless match.tournament_match?
team_name = match.team&.name.to_s
return false if team_name.blank?
[
"#{team_name} vs #{match.opponent_name}",
"#{team_name} vs #{match.away_display_name}"
].include?(stored)
end
def normalize_privacy_status def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private" self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank? self.privacy_status = "unlisted" if privacy_status.blank?
@@ -0,0 +1,71 @@
# frozen_string_literal: true
class StreamConcurrencyViolation < ApplicationRecord
LOOKBACK = 30.days
belongs_to :user, optional: true
belongs_to :occupying_session, class_name: "StreamSession", optional: true
belongs_to :attempted_session, class_name: "StreamSession", optional: true
belongs_to :occupying_club, class_name: "Club", optional: true
belongs_to :attempted_club, class_name: "Club", optional: true
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
validates :attempt_action, inclusion: { in: %w[start resume] }
scope :recent, -> { order(created_at: :desc) }
scope :since, ->(time) { where("created_at >= ?", time) }
scope :lookback, -> { since(LOOKBACK.ago) }
scope :two_devices, -> { where(devices_differ: true) }
scope :for_club, lambda { |club_id|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
}
scope :for_session, lambda { |session_id|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
}
def self.record!(attempted:, occupying:, action: "start")
user = attempted.user || occupying.user
occupying_club = occupying.match&.team&.club
attempted_club = attempted.match&.team&.club
occupying_device = occupying.client_device_label
attempted_device = attempted.client_device_label
create!(
user: user,
occupying_session: occupying,
attempted_session: attempted,
occupying_club: occupying_club,
attempted_club: attempted_club,
attempt_action: action.to_s,
user_email: user&.email.presence || "unknown",
user_name: user&.name,
occupying_club_name: occupying_club&.name,
attempted_club_name: attempted_club&.name,
occupying_match_label: occupying.match_label,
attempted_match_label: attempted.match_label,
occupying_status: occupying.status,
occupying_device: occupying_device,
attempted_device: attempted_device,
devices_differ: StreamSession.devices_differ?(occupying, attempted),
metadata: {
occupying_session_id: occupying.id,
attempted_session_id: attempted.id,
occupying_client_os: occupying.client_os,
attempted_client_os: attempted.client_os,
occupying_app_version: occupying.app_version,
attempted_app_version: attempted.app_version
}.compact
)
rescue StandardError => e
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
nil
end
def club_name
attempted_club_name.presence || occupying_club_name
end
def operator_label
[user_name.presence, user_email].compact.join(" · ")
end
end
+39
View File
@@ -10,6 +10,10 @@ class StreamSession < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :stream_node, optional: true belongs_to :stream_node, optional: true
has_many :stream_events, dependent: :destroy has_many :stream_events, dependent: :destroy
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy has_one :score_state, dependent: :destroy
has_one :recording has_one :recording
has_many :device_states, dependent: :destroy has_many :device_states, dependent: :destroy
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
stream_node&.role.presence || ingest_role stream_node&.role.presence || ingest_role
end end
def match_label
team_name = match&.team&.name
opponent = match&.opponent_name
return id.to_s if team_name.blank?
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
end
def client_device_label
parts = []
parts << client_os if client_os.present?
device = [device_manufacturer, device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{os_version}" if os_version.present?
parts.join(" · ").presence
end
def client_device_key
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
end
def self.devices_differ?(left, right)
ka = left.client_device_key
kb = right.client_device_key
return false if ka.delete("|").blank? || kb.delete("|").blank?
ka != kb
end
def rtmp_ingest_url def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti). # RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path). # MediaMTX path = live/match_{uuid} (no ?token= nel path).
@@ -153,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present? platform == "youtube" && youtube_watch_url.present?
end end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only? def link_only?
privacy_status.in?(%w[unlisted private]) privacy_status.in?(%w[unlisted private])
end end
+10
View File
@@ -2,6 +2,8 @@ class Team < ApplicationRecord
include Brandable include Brandable
include Coverable include Coverable
INTERNAL_KINDS = %w[tournament_broadcast].freeze
belongs_to :club belongs_to :club
has_many :user_teams, dependent: :destroy has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams has_many :users, through: :user_teams
@@ -9,6 +11,10 @@ class Team < ApplicationRecord
has_many :recordings, dependent: :destroy has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy has_many :team_invitations, dependent: :destroy
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
has_one :broadcast_tournament, class_name: "Tournament", foreign_key: :broadcast_team_id, inverse_of: :broadcast_team
scope :visible, -> { where("internal_kind IS NULL OR internal_kind = ''") }
scope :tournament_broadcast, -> { where(internal_kind: Tournament::INTERNAL_TEAM_KIND) }
has_one_attached :photo_file has_one_attached :photo_file
@@ -61,6 +67,10 @@ class Team < ApplicationRecord
Rails.application.routes.url_helpers.public_team_page_path(slug) Rails.application.routes.url_helpers.public_team_page_path(slug)
end end
def tournament_broadcast?
internal_kind == Tournament::INTERNAL_TEAM_KIND
end
def sport_label def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end end
+145
View File
@@ -0,0 +1,145 @@
class Tournament < ApplicationRecord
include Coverable
FORMAT_KINDS = %w[groups knockout mixed free].freeze
STATUSES = %w[draft published live archived].freeze
INTERNAL_TEAM_KIND = "tournament_broadcast"
belongs_to :club
belongs_to :broadcast_team, class_name: "Team", optional: true
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_many :matches, dependent: :destroy
has_many :broadcast_invitations, class_name: "TournamentBroadcastInvitation", dependent: :destroy
has_many :participants, class_name: "TournamentParticipant", dependent: :destroy
has_many :rounds, class_name: "TournamentRound", dependent: :destroy
has_many :groups, class_name: "TournamentGroup", dependent: :destroy
has_many_attached :invite_email_images
has_one_attached :logo_file
validates :name, presence: true
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
validates :sport_key, presence: true
validates :starts_on, :ends_on, presence: true
validates :format_kind, inclusion: { in: FORMAT_KINDS }
validates :status, inclusion: { in: STATUSES }
validates :invite_draft_note, length: { maximum: 2000 }, allow_blank: true
validate :sport_key_known
validate :ends_on_not_before_starts_on
validate :logo_file_type, if: -> { logo_file.attached? }
before_validation :normalize_sport_key
before_validation :assign_slug, on: :create
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
before_validation :normalize_courts
scope :visible_to_public, -> { where(status: %w[published live archived]) }
scope :listed_on_live, -> { where(status: %w[published live]) }
scope :search_public, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
left_joins(:club).where(
"tournaments.name ILIKE :term OR clubs.name ILIKE :term OR tournaments.venue ILIKE :term",
term: term
)
}
def published?
status.in?(%w[published live archived])
end
def archived?
status == "archived"
end
def writable?
!archived?
end
def uses_groups?
format_kind.in?(%w[groups mixed])
end
def uses_knockout?
format_kind.in?(%w[knockout mixed])
end
def court_list
Array(courts).map { |c| c.to_s.strip }.reject(&:blank?)
end
def days
(starts_on..ends_on).to_a
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def public_page_path
Rails.application.routes.url_helpers.public_tournament_page_path(slug)
end
def effective_primary_color
club.effective_primary_color
end
def effective_secondary_color
club.effective_secondary_color
end
def effective_logo_url
return unless logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
end
def concurrent_limit
club.subscription&.plan&.concurrent_streams_limit
end
private
def normalize_sport_key
self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
end
def sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
errors.add(:sport_key, "non valido")
end
def ends_on_not_before_starts_on
return if starts_on.blank? || ends_on.blank?
return if ends_on >= starts_on
errors.add(:ends_on, "non può precedere la data di inizio")
end
def assign_slug
self.slug = Tournaments::GenerateSlug.call(self) if slug.blank?
end
def normalize_slug
self.slug = slug.to_s.parameterize
end
def normalize_courts
self.courts = court_list
self.courts = ["Campo 1"] if courts.blank?
end
def cover_parent
club
end
def logo_file_type
return if logo_file.content_type.in?(Coverable::COVER_IMAGE_TYPES)
errors.add(:logo_file, I18n.t("coverable.errors.invalid_type"))
end
end
@@ -0,0 +1,8 @@
class TournamentBroadcastAssignment < ApplicationRecord
belongs_to :tournament
belongs_to :match
belongs_to :user
belongs_to :invitation, class_name: "TournamentBroadcastInvitation", optional: true
validates :user_id, uniqueness: { scope: :match_id }
end
@@ -0,0 +1,55 @@
class TournamentBroadcastInvitation < ApplicationRecord
SCOPE_KINDS = %w[matches court_day].freeze
belongs_to :tournament
belongs_to :accepted_by, class_name: "User", optional: true
has_many :assignments, class_name: "TournamentBroadcastAssignment",
foreign_key: :invitation_id, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token_digest, presence: true, uniqueness: true
validates :scope_kind, inclusion: { in: SCOPE_KINDS }
validates :court, presence: true, if: -> { scope_kind == "court_day" }
validates :on_date, presence: true, if: -> { scope_kind == "court_day" }
validates :note, length: { maximum: 2000 }, allow_blank: true
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def self.generate_token
SecureRandom.urlsafe_base64(32)
end
def expired?
expires_at.past?
end
def covers_match?(match)
return false if match.blank? || match.tournament_id != tournament_id
case scope_kind
when "court_day"
return false if court.blank? || on_date.blank? || match.scheduled_at.blank?
court.to_s == match.court.to_s && on_date == match.scheduled_at.in_time_zone.to_date
else
Array(match_ids).map(&:to_s).include?(match.id.to_s)
end
end
def accept!(user)
transaction do
update!(accepted_at: Time.current, accepted_by: user)
Tournaments::GrantBroadcastAccess.call(invitation: self, user: user)
Tournaments::SyncAssignments.call(invitation: self)
end
end
def scope_label
if scope_kind == "court_day"
"#{court} · #{I18n.l(on_date)}"
else
"#{Array(match_ids).size} partite"
end
end
end
+8
View File
@@ -0,0 +1,8 @@
class TournamentGroup < ApplicationRecord
belongs_to :tournament
has_many :participants, class_name: "TournamentParticipant", foreign_key: :group_id, dependent: :nullify
has_many :matches, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
@@ -0,0 +1,35 @@
class TournamentParticipant < ApplicationRecord
include Brandable
belongs_to :tournament
belongs_to :group, class_name: "TournamentGroup", optional: true
belongs_to :source_team, class_name: "Team", optional: true
has_many :home_matches, class_name: "Match", foreign_key: :home_participant_id, dependent: :nullify
has_many :away_matches, class_name: "Match", foreign_key: :away_participant_id, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
after_update :sync_related_match_names, if: :saved_change_to_name?
def branding_parent
source_team || tournament
end
def effective_logo_url
if logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
elsif logo_url.present?
logo_url
else
source_team&.effective_logo_url
end
end
private
def sync_related_match_names
Match.where(home_participant_id: id).or(Match.where(away_participant_id: id)).find_each(&:save!)
end
end
+10
View File
@@ -0,0 +1,10 @@
class TournamentRound < ApplicationRecord
KINDS = %w[round_of_16 quarterfinal semifinal final third_place].freeze
belongs_to :tournament
has_many :matches, dependent: :nullify
validates :kind, inclusion: { in: KINDS }
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
+16 -3
View File
@@ -11,16 +11,20 @@ class User < ApplicationRecord
has_many :clubs, through: :club_memberships has_many :clubs, through: :club_memberships
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
has_many :tournament_broadcast_assignments, dependent: :destroy
def manageable_teams def manageable_teams
staff_ids = teams.select(:id) staff_ids = teams.select(:id)
owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id) owner_ids = Team.visible.where(club_id: owned_clubs.select(:id)).select(:id)
Team.where(id: staff_ids).or(Team.where(id: owner_ids)) Team.where(id: staff_ids).or(Team.where(id: owner_ids))
end end
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app). # Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
def streamable_teams def streamable_teams
manageable_teams.includes(:club).select { |team| can_stream_for?(team) } listed = manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
owned_broadcast = Team.tournament_broadcast.where(club_id: owned_clubs.select(:id)).includes(:club)
(listed + owned_broadcast.to_a).uniq
end end
def club_admin?(club) def club_admin?(club)
@@ -28,6 +32,7 @@ class User < ApplicationRecord
end end
def can_schedule_for?(team) def can_schedule_for?(team)
return false if team.tournament_broadcast?
return true if team.club&.owned_by?(self) return true if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team) membership = user_teams.find_by(team: team)
@@ -37,7 +42,7 @@ class User < ApplicationRecord
end end
def schedulable_teams_for(club) def schedulable_teams_for(club)
teams = club.teams.order(:name).to_a teams = club.teams.visible.order(:name).to_a
return teams if club.owned_by?(self) return teams if club.owned_by?(self)
teams.select { |team| can_schedule_for?(team) } teams.select { |team| can_schedule_for?(team) }
@@ -54,6 +59,14 @@ class User < ApplicationRecord
Teams::StaffCoverage.new(team).covers_both_roles?(membership) Teams::StaffCoverage.new(team).covers_both_roles?(membership)
end end
def can_broadcast_match?(match)
return true if match.team.club&.owned_by?(self)
return false unless can_stream_for?(match.team)
return true unless match.tournament_match?
tournament_broadcast_assignments.exists?(match_id: match.id)
end
def staff_role_for(team) def staff_role_for(team)
return "owner" if team.club&.owned_by?(self) return "owner" if team.club&.owned_by?(self)
@@ -0,0 +1,150 @@
# frozen_string_literal: true
module Admin
class CostAnalytics
TREND_MONTHS = 12
def initialize(month:)
@month = month.to_date.beginning_of_month
end
def summary
build_period(@month)
end
def trend(months: TREND_MONTHS)
start_month = @month - (months - 1).months
months_list = (0...months).map { |i| start_month + i.months }
months_list.map { |m| build_period(m) }
end
def club_breakdown
range = month_range(@month)
rows = session_rows(range)
total_secs = rows.sum { |row| row.total_secs.to_i }
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
revenue_by_club = revenue_by_club(range)
storage_by_club = storage_by_club_index
club_ids = rows.map(&:club_id)
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
rows.map do |row|
club = clubs[row.club_id]
secs = row.total_secs.to_i
hours = secs / 3600.0
sessions_count = row.sessions_count.to_i
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
allocated_cents = (total_cost_cents * share).round
revenue_cents = revenue_by_club[row.club_id].to_i
storage_bytes = storage_by_club[row.club_id].to_i
{
club_id: row.club_id,
club_name: row.club_name,
plan_slug: club&.subscription&.plan&.slug,
sessions: sessions_count,
hours: hours.round(2),
hours_share_pct: (share * 100).round(1),
allocated_cost_cents: allocated_cents,
revenue_cents: revenue_cents,
margin_cents: revenue_cents - allocated_cents,
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
storage_bytes: storage_bytes
}
end.sort_by { |row| [-row[:hours], row[:club_name]] }
end
private
def build_period(month)
range = month_range(month)
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
sessions_scope = ended_sessions.where(ended_at: range)
total_secs = sessions_scope.sum(:total_duration_secs).to_i
hours = total_secs / 3600.0
sessions = sessions_scope.count
clubs_active = distinct_active_clubs(range)
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
storage_bytes = Recording.ready.sum(:byte_size).to_i
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
kpis = compute_kpis(
cost_cents: cost_cents,
hours: hours,
sessions: sessions,
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_gb: storage_gb
)
{
month: month,
cost_cents: cost_cents,
sessions: sessions,
hours: hours.round(2),
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_bytes: storage_bytes,
**kpis
}
end
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
margin_cents = revenue_cents.to_i - cost_cents.to_i
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
hours_f = hours.to_f
sessions_i = sessions.to_i
clubs_i = clubs_active.to_i
storage_f = storage_gb.to_f
cost_i = cost_cents.to_i
{
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
margin_cents: margin_cents,
margin_pct: margin_pct
}
end
def month_range(month)
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
end
def ended_sessions
StreamSession.where(status: "ended")
end
def distinct_active_clubs(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.distinct
.count("clubs.id")
end
def session_rows(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.group("clubs.id", "clubs.name")
.select(
"clubs.id AS club_id",
"clubs.name AS club_name",
"COUNT(stream_sessions.id) AS sessions_count",
"SUM(stream_sessions.total_duration_secs) AS total_secs"
)
end
def revenue_by_club(range)
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
end
def storage_by_club_index
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Analytics
module Suppress
COOKIE_NAME = "mltv_analytics_suppress"
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
module_function
def active?(cookie_value)
cookie_value.to_s == "1"
end
def enable!(cookie_jar)
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
end
def disable!(cookie_jar)
cookie_jar.delete(
COOKIE_NAME,
path: "/",
same_site: :lax,
secure: cookie_secure?
)
end
def cookie_options(value:, expires:)
{
value: value,
expires: expires,
path: "/",
httponly: true,
same_site: :lax,
secure: cookie_secure?
}
end
def cookie_secure?
Rails.application.config.force_ssl || Rails.env.production?
end
end
end
@@ -54,6 +54,8 @@ module Ops
def process_finding(finding) def process_finding(finding)
if finding.healthy if finding.healthy
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint) Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
# overflow usa fingerprint diverse (at_max / orphan_idle / budget) rispetto al check sano
Ops::IncidentRecorder.resolve_kind(finding.kind) if finding.kind == "stream_overflow"
else else
Ops::IncidentRecorder.record( Ops::IncidentRecorder.record(
kind: finding.kind, kind: finding.kind,
@@ -10,6 +10,10 @@ module Ops
def resolve(fingerprint:) def resolve(fingerprint:)
new.resolve(fingerprint: fingerprint) new.resolve(fingerprint: fingerprint)
end end
def resolve_kind(kind)
new.resolve_kind(kind)
end
end end
def record(finding) def record(finding)
@@ -49,6 +53,10 @@ module Ops
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!) Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
end end
def resolve_kind(kind)
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
end
private private
def fingerprint_for(finding) def fingerprint_for(finding)
@@ -73,7 +73,7 @@ module Recordings
def default_title def default_title
match = @session.match match = @session.match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label.presence || "Replay"
end end
def privacy_from_session def privacy_from_session
@@ -23,7 +23,7 @@ module Recordings
private private
def deliver_expiring_soon(user) def deliver_expiring_soon(user)
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user))
rescue EOFError => e rescue EOFError => e
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}") Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end end
@@ -19,7 +19,7 @@ module Recordings
private private
def deliver_replay_ready(user) def deliver_replay_ready(user)
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user))
rescue EOFError => e rescue EOFError => e
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita. # Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}") Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
@@ -35,9 +35,9 @@ module Recordings
FileUtils.remove_entry(dest) FileUtils.remove_entry(dest)
return nil return nil
end end
unless res.is_a?(Net::HTTPSuccess) && res.body.present? unless res.is_a?(Net::HTTPSuccess) && body_present?(res.body)
FileUtils.remove_entry(dest) 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 end
tar_path = File.join(dest, "recordings.tar.gz") tar_path = File.join(dest, "recordings.tar.gz")
@@ -84,6 +84,14 @@ module Recordings
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret" ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end 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) def unpack!(tar_path, dest)
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL) ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
raise Error, "tar extract failed" unless ok raise Error, "tar extract failed" unless ok
@@ -0,0 +1,51 @@
# frozen_string_literal: true
module Sessions
class AssertUserConcurrent
ERROR_CODE = "user_concurrent_stream"
def self.with_lock(session, action: "start")
new(session, action: action).with_lock { yield }
end
def initialize(session, action: "start")
@session = session
@action = action.to_s
end
def with_lock
occupying = nil
User.transaction do
User.lock.find(@session.user_id) if @session.user_id.present?
occupying = occupying_session
yield if occupying.nil?
end
return if occupying.nil?
StreamConcurrencyViolation.record!(
attempted: @session,
occupying: occupying,
action: @action
)
raise Teams::EntitlementError.new(
I18n.t("api.errors.user_concurrent_stream"),
code: ERROR_CODE
)
end
private
def occupying_session
return if @session.user_id.blank?
StreamSession.broadcasting
.where(user_id: @session.user_id)
.where.not(id: @session.id)
.includes(:user, match: { team: :club })
.order(Arel.sql("COALESCE(started_at, updated_at) DESC"))
.first
end
end
end
+4 -2
View File
@@ -10,8 +10,10 @@ module Sessions
end end
cancel_timeout_job cancel_timeout_job
# connecting finché RTMP non è online (evita lose_connection da PublisherSync) Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
@session.begin_connect! if @session.may_begin_connect? # connecting finché RTMP non è online (evita lose_connection da PublisherSync)
@session.begin_connect! if @session.may_begin_connect?
end
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher). # Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
log_event("resumed") log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
+5 -3
View File
@@ -5,9 +5,11 @@ module Sessions
end end
def call def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
@session.begin_connect! if @session.may_begin_connect? @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.update!(status: "connecting") unless @session.connecting? @session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
end
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube" Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
broadcast_status("connecting") broadcast_status("connecting")
@session @session
+5
View File
@@ -15,6 +15,11 @@ module Sessions
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing" Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
remove_mediamtx_paths! 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") log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session @session
+12 -1
View File
@@ -71,6 +71,10 @@ module Streams
estimated_monthly_eur(overflow_count) <= monthly_budget_eur estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end end
def quiet_hours?
QuietHours.active?
end
def reconcile!(provisioner: nil) def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled? return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
@@ -106,7 +110,8 @@ module Streams
allow_cloud: allow_cloud?, allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size), estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur, monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size) within_budget: within_budget?(overflow.size),
quiet_hours: quiet_hours?
} }
end end
@@ -201,6 +206,7 @@ module Streams
end end
def warm_spare_desired?(m) def warm_spare_desired?(m)
return false if self.class.quiet_hours?
return false if self.class.warm_spare_min <= 0 return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use? need_capacity?(m) || overflow_in_use?
@@ -211,6 +217,7 @@ module Streams
end end
def can_provision?(m) def can_provision?(m)
return false if self.class.quiet_hours?
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1) return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud? return false if self.class.kind == "cloud" && !self.class.allow_cloud?
@@ -249,6 +256,9 @@ module Streams
end end
def idle_long_enough?(node) 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 idle_since(node) <= self.class.idle_minutes.minutes.ago
end end
@@ -258,6 +268,7 @@ module Streams
end end
def keep_as_warm_spare?(node) def keep_as_warm_spare?(node)
return false if self.class.quiet_hours?
return false unless warm_spare_desired?(self.class.metrics) return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? } 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 end
def base_teams 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 = scope.where(sport_key: @sport) if @sport
scope = apply_search(scope) if @q.present? scope = apply_search(scope) if @q.present?
scope.to_a 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
@@ -13,7 +13,7 @@ module Users
return if user.nil? return if user.nil?
token = user.generate_password_reset! token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now MatchLiveTv.deliver_mail(UserMailer.password_reset(user, token))
end end
end end
end end
@@ -5,6 +5,32 @@
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p> <p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
</div> </div>
<section class="panel admin-analytics-preview">
<h3><%= t("admin.analytics.preview.title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
<% if @analytics_preview_active %>
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.disable"),
admin_analytics_preview_path,
method: :delete,
class: "admin-btn admin-btn--outline admin-btn--sm" %>
<%= link_to t("admin.analytics.preview.open_site"),
root_path,
class: "admin-btn admin-btn--primary admin-btn--sm",
target: "_blank",
rel: "noopener" %>
</div>
<% else %>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.enable"),
admin_analytics_preview_path(return_to: root_path),
method: :post,
class: "admin-btn admin-btn--primary admin-btn--sm" %>
</div>
<% end %>
</section>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
@@ -58,8 +84,9 @@
<td class="muted"><%= avg %>%</td> <td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td> <td class="muted"><%= row[:max_scroll] %>%</td>
<td> <td>
<%= link_to t("admin.analytics.index.heatmap"), <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
+42 -14
View File
@@ -3,7 +3,7 @@
<div class="admin-page-head admin-session-head"> <div class="admin-page-head admin-session-head">
<div> <div>
<p class="muted admin-page-sub"> <p class="muted admin-page-sub">
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
</p> </p>
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2> <h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
<p><code class="admin-mono"><%= @page_path %></code></p> <p><code class="admin-mono"><%= @page_path %></code></p>
@@ -11,8 +11,32 @@
</div> </div>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
</p>
<div class="admin-locale-tabs" role="tablist">
<% AnalyticsEvent::DEVICES.each do |device| %>
<% stats = @device_tab_stats[device] %>
<% active = @filters[:device] == device %>
<%= link_to admin_analytics_page_path(
page_path: @page_path,
from: @filters[:from],
to: @filters[:to],
device: device,
layer: @filters[:layer]
),
class: "admin-locale-tab #{'is-active' if active}",
role: "tab",
"aria-selected": active do %>
<%= t("admin.analytics.devices.#{device}") %>
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
<% end %>
<% end %>
</div>
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
<%= hidden_field_tag :page_path, @page_path %> <%= hidden_field_tag :page_path, @page_path %>
<%= hidden_field_tag :device, @filters[:device] %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span> <span><%= t("admin.analytics.filters.from") %></span>
@@ -22,14 +46,6 @@
<span><%= t("admin.analytics.filters.to") %></span> <span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %> <%= date_field_tag :to, @filters[:to] %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</label>
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.layer") %></span> <span><%= t("admin.analytics.filters.layer") %></span>
<%= select_tag :layer, <%= select_tag :layer,
@@ -91,7 +107,12 @@
<h3> <h3>
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %> <%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
</h3> </h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p> <p class="muted admin-table-sub">
<%= t(
"admin.analytics.show.heatmap_hint",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<% if @cells.any? %> <% if @cells.any? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage"> <div class="admin-heatmap-stage" id="admin-heatmap-stage">
@@ -115,13 +136,20 @@
></canvas> ></canvas>
</div> </div>
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;"> <p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta", <%= t(
device: @snapshot.device, "admin.analytics.show.snapshot_meta",
at: l(@snapshot.captured_at, format: :short)) %> device: t("admin.analytics.devices.#{@snapshot.device}"),
at: l(@snapshot.captured_at, format: :short)
) %>
</p> </p>
<% else %> <% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div> <div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p> <p class="muted admin-heatmap-fallback-note">
<%= t(
"admin.analytics.show.snapshot_missing",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<canvas <canvas
id="admin-heatmap" id="admin-heatmap"
class="admin-heatmap-overlay" class="admin-heatmap-overlay"
@@ -46,3 +46,14 @@
</tbody> </tbody>
</table> </table>
<% end %> <% end %>
<h3 style="font-size:1rem;margin-top:28px"><%= t("admin.clubs.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.clubs.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.clubs.show.concurrency_none" %>
<% if @concurrency_violations.any? %>
<p class="kpi-sub" style="margin-top:0.75rem">
<%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %>
</p>
<% end %>
@@ -0,0 +1,34 @@
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.month") %></span>
<%= text_field_tag "platform_cost_entry[month]",
@entry.month&.strftime("%Y-%m"),
type: "month",
required: true %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.label") %></span>
<%= f.text_field :label, required: true, maxlength: 120 %>
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.amount") %></span>
<%= number_field_tag "platform_cost_entry[amount_euros]",
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
step: 0.01,
min: 0.01,
required: true %>
</label>
<label class="admin-filter-field" style="grid-column: span 2;">
<span><%= t("admin.costs.entries.notes") %></span>
<%= f.text_area :notes, rows: 3 %>
</label>
</div>
<div class="admin-filter-actions">
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.entries.cancel"),
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
@@ -0,0 +1,10 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
</section>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entries_path, method: :post %>
</section>
@@ -0,0 +1,197 @@
<% content_for :body_class, "admin-body" %>
<% content_for :head do %>
<script>
window.adminCostTrend = <%= raw @trend.to_json %>;
window.adminCostI18n = {
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
hours: <%= raw t("admin.costs.charts.hours").to_json %>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-costs.js?v=1" defer></script>
<% end %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.index.month") %></span>
<%= select_tag :month,
options_for_select(
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
@month.strftime("%Y-%m")
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.index.add_entry"),
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
</div>
<section class="kpi-grid">
<div class="kpi-card kpi-card--accent">
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
<% if @summary[:margin_pct] %>
· <%= @summary[:margin_pct] %>%
<% end %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
· <%= t("admin.costs.kpi.storage_note") %>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.trends_title") %></h3>
<div class="charts-grid">
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.hours") %></h3>
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.entries_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
<% if @entries.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.label") %></th>
<th><%= t("admin.costs.table.amount") %></th>
<th><%= t("admin.costs.table.notes") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr>
<td><strong><%= entry.label %></strong></td>
<td><%= format_euros(entry.amount_cents) %></td>
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"),
edit_admin_cost_entry_path(entry),
class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.costs.entries.delete"),
admin_cost_entry_path(entry),
method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr>
<th><%= t("admin.costs.kpi.platform_cost") %></th>
<th><%= format_euros(@summary[:cost_cents]) %></th>
<th colspan="2"></th>
</tr>
</tfoot>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.costs.index.clubs_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
<% if @clubs.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.club") %></th>
<th><%= t("admin.costs.table.plan") %></th>
<th><%= t("admin.costs.table.hours") %></th>
<th><%= t("admin.costs.table.hours_share") %></th>
<th><%= t("admin.costs.table.allocated_cost") %></th>
<th><%= t("admin.costs.table.revenue") %></th>
<th><%= t("admin.costs.table.margin") %></th>
<th><%= t("admin.costs.table.cost_per_hour") %></th>
<th><%= t("admin.costs.table.cost_per_session") %></th>
<th><%= t("admin.costs.table.storage") %></th>
</tr>
</thead>
<tbody>
<% @clubs.each do |row| %>
<tr>
<td>
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
</td>
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
<td><%= format_hours(row[:hours]) %></td>
<td class="muted"><%= row[:hours_share_pct] %>%</td>
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
<td><%= format_euros(row[:revenue_cents]) %></td>
<td><%= format_euros(row[:margin_cents]) %></td>
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
<% end %>
</section>
@@ -67,6 +67,24 @@
<% end %> <% end %>
</section> </section>
<section class="panel" style="margin-bottom:1.25rem">
<h2><%= t("admin.dashboard.concurrency_panel.title") %></h2>
<% if @concurrency_violation_lookback.positive? %>
<p class="kpi-sub" style="margin-bottom:0.75rem">
<%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %>
<%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %>
</p>
<%= render "admin/stream_concurrency_violations/table",
violations: @recent_concurrency_violations,
empty_key: "admin.dashboard.concurrency_panel.none" %>
<% else %>
<p class="empty" style="margin:0">
<%= t("admin.dashboard.concurrency_panel.none_html",
link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %>
</p>
<% end %>
</section>
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));"> <section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="chart-card"> <div class="chart-card">
<h3><%= t("admin.dashboard.disk.system_title") %></h3> <h3><%= t("admin.dashboard.disk.system_title") %></h3>
@@ -258,6 +258,16 @@
</section> </section>
<% end %> <% end %>
<% if @concurrency_violations.any? %>
<section class="panel">
<h3><%= t("admin.sessions.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.sessions.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.sessions.show.concurrency_none" %>
</section>
<% end %>
<section class="panel"> <section class="panel">
<h3><%= t("admin.sessions.show.events_title") %></h3> <h3><%= t("admin.sessions.show.events_title") %></h3>
<% if @events.any? %> <% if @events.any? %>
@@ -0,0 +1,67 @@
<% if violations.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.stream_concurrency.table.when") %></th>
<th><%= t("admin.stream_concurrency.table.account") %></th>
<th><%= t("admin.stream_concurrency.table.club") %></th>
<th><%= t("admin.stream_concurrency.table.occupying") %></th>
<th><%= t("admin.stream_concurrency.table.attempted") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% violations.each do |row| %>
<tr class="<%= 'admin-row--two-devices' if row.devices_differ? %>">
<td class="muted"><%= admin_datetime(row.created_at, with_seconds: true) %></td>
<td>
<strong><%= row.user_name.presence || t("admin.common.dash") %></strong>
<div class="muted" style="font-size:0.85rem"><%= row.user_email %></div>
</td>
<td>
<% if row.attempted_club %>
<%= link_to row.club_name, admin_club_path(row.attempted_club) %>
<% elsif row.occupying_club %>
<%= link_to row.club_name, admin_club_path(row.occupying_club) %>
<% else %>
<%= row.club_name.presence || t("admin.common.dash") %>
<% end %>
</td>
<td>
<% if row.occupying_session %>
<%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %>
<% else %>
<%= row.occupying_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<span class="badge <%= admin_session_status_badge_class(row.occupying_status) %>"><%= row.occupying_status %></span>
<%= row.occupying_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.attempted_session %>
<%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %>
<% else %>
<%= row.attempted_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<%= t("admin.stream_concurrency.action.#{row.attempt_action}") %>
· <%= row.attempted_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.devices_differ? %>
<span class="badge badge--abuse"><%= t("admin.stream_concurrency.badge.two_devices") %></span>
<% else %>
<span class="muted"><%= t("admin.stream_concurrency.badge.same_or_unknown") %></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t(empty_key) %></p>
<% end %>
@@ -0,0 +1,48 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.stream_concurrency.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.stream_concurrency.index.lead") %></p>
</div>
<section class="kpi-grid">
<div class="kpi-card <%= @lookback_count.positive? ? 'kpi-card--accent' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.lookback") %></div>
<div class="kpi-value"><%= @lookback_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.lookback_sub") %></div>
</div>
<div class="kpi-card <%= @two_devices_count.positive? ? 'kpi-card--danger' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.two_devices") %></div>
<div class="kpi-value"><%= @two_devices_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.two_devices_sub") %></div>
</div>
</section>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.stream_concurrency.filters.q") %></span>
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.stream_concurrency.filters.q_placeholder") %>
</label>
<label class="admin-filter-field admin-filter-field--check">
<span><%= t("admin.stream_concurrency.filters.two_devices") %></span>
<label class="admin-checkbox">
<%= check_box_tag :devices_differ, "1", @filters[:devices_differ] %>
<%= t("admin.stream_concurrency.filters.two_devices_hint") %>
</label>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
<span class="muted admin-filter-count">
<%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %>
</span>
</div>
<% end %>
</div>
<div class="panel">
<%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %>
</div>
+7 -1
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15"> <link rel="stylesheet" href="/admin.css?v=16">
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
<% end %> <% end %>
@@ -31,7 +32,12 @@
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %> <%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %> <%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
<% abuse_count = admin_concurrency_violation_lookback_count %>
<%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %>
<%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <span class="admin-nav-badge"><%= abuse_count %></span><% end %>
<% end %>
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %> <%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %> <%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %> <%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
+6 -3
View File
@@ -7,9 +7,10 @@
<meta name="application-name" content="Match Live TV"> <meta name="application-name" content="Match Live TV">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=76"> <link rel="stylesheet" href="/marketing.css?v=102">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -22,11 +23,13 @@
</main> </main>
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %> <%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script> <script src="/branding-form.js?v=1" defer></script>
<script src="/tournament-invite-editor.js?v=2" defer></script>
<script src="/tournament-calendar.js?v=2" defer></script>
<script src="/roster-form.js?v=1" defer></script> <script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -6,8 +6,9 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=76"> <link rel="stylesheet" href="/marketing.css?v=102">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -21,7 +22,7 @@
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -21,6 +21,9 @@
<%= hidden_field_tag :plan, plan_return if plan_return %> <%= hidden_field_tag :plan, plan_return if plan_return %>
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %> <%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
<%= render "shared/billing_profile_fields", record: @club %> <%= render "shared/billing_profile_fields", record: @club %>
<% if plan_return %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %> <%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
<% end %> <% end %>
</div> </div>
@@ -21,6 +21,9 @@
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %> <%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
<% elsif MatchLiveTv.stripe_enabled? %> <% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %> <%= render "shared/stripe_secure_payment" %>
<% if @subscription.blank? || @subscription.plan&.slug == "free" || !@subscription.active? %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<% end %> <% end %>
<%= render "shared/pending_bank_transfer", order: @pending_transfer %> <%= render "shared/pending_bank_transfer", order: @pending_transfer %>
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %> <% if MatchLiveTv.stripe_enabled? && @quote.blank? %>

Some files were not shown because too many files have changed in this diff Show More