diff --git a/README.md b/README.md index d902b54..bf87585 100644 --- a/README.md +++ b/README.md @@ -66,5 +66,5 @@ Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:300 ```bash ./scripts/run_mobile_android_prod.sh -# API: https://matchlivetv.eminux.it +# API: https://www.matchlivetv.it ``` diff --git a/backend/app/channels/application_cable/connection.rb b/backend/app/channels/application_cable/connection.rb index e1044a9..0e0ea46 100644 --- a/backend/app/channels/application_cable/connection.rb +++ b/backend/app/channels/application_cable/connection.rb @@ -1,8 +1,14 @@ module ApplicationCable class Connection < ActionCable::Connection::Base - identified_by :current_user + identified_by :current_user, :regia_session def connect + if (regia_token = request.params[:regia_token].presence) + self.regia_session = Sessions::RegiaAccess.find_session_by_token(regia_token) + reject_unauthorized_connection unless regia_session + return + end + payload = JsonWebToken.decode(token_from_header) self.current_user = User.find_by(id: payload[:user_id]) if payload reject_unauthorized_connection unless current_user diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index 1fe4381..dbf30e5 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -1,19 +1,31 @@ class SessionChannel < ApplicationCable::Channel include CableBroadcastable def subscribed - session = StreamSession.joins(match: :team) - .merge(current_user.manageable_teams) - .find(params[:session_id]) + session = resolve_session + reject unless session && session.id.to_s == params[:session_id].to_s + stream_for session session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller") end + def resolve_session + if connection.regia_session + connection.regia_session + else + StreamSession.joins(match: :team) + .merge(connection.current_user.manageable_teams) + .find_by(id: params[:session_id]) + end + end + def unsubscribed stop_all_streams end def receive(data) - session = StreamSession.find(params[:session_id]) + session = resolve_session + return unless session + handle_message(session, data) end diff --git a/backend/app/controllers/admin/billing_invoices_controller.rb b/backend/app/controllers/admin/billing_invoices_controller.rb new file mode 100644 index 0000000..1eadf23 --- /dev/null +++ b/backend/app/controllers/admin/billing_invoices_controller.rb @@ -0,0 +1,50 @@ +module Admin + class BillingInvoicesController < BaseController + before_action :set_club + + def index + @invoices = @club.billing_invoices.recent + end + + def new + @invoice = @club.billing_invoices.build(issued_on: Date.current, currency: "eur", source: "aruba") + end + + def create + @invoice = @club.billing_invoices.build(invoice_params) + @invoice.source = "aruba" + + if @invoice.save + redirect_to admin_club_billing_invoices_path(@club), notice: "Fattura #{@invoice.number} registrata." + else + flash.now[:alert] = @invoice.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + private + + def set_club + @club = Club.find(params[:club_id]) + end + + def invoice_params + params.require(:billing_invoice).permit( + :number, + :issued_on, + :amount_cents, + :amount_euros, + :currency, + :status, + :aruba_document_id, + :billing_payment_id, + :notes, + :pdf + ).tap do |p| + if p[:amount_euros].present? + p[:amount_cents] = (p.delete(:amount_euros).to_f * 100).round + end + end + end + end +end diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb index cdf87e2..6445242 100644 --- a/backend/app/controllers/admin/sessions_controller.rb +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -8,5 +8,19 @@ module Admin @session = StreamSession.find(params[:id]) @events = @session.stream_events.recent.limit(50) end + + def stop + @session = StreamSession.find(params[:id]) + if @session.terminal? + redirect_to admin_session_path(@session), alert: "Sessione già terminata (#{@session.status})." + return + end + + Sessions::Stop.new(@session).call + redirect_to admin_session_path(@session), notice: "Sessione terminata." + rescue StandardError => e + Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}") + redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}" + end end end diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 0d78f7a..9238aa9 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -40,11 +40,13 @@ module Api private def set_team - @team = current_user.manageable_teams.find(params[:team_id]) + @team = current_user.streamable_teams.find { |t| t.id == params[:team_id] } + raise ActiveRecord::RecordNotFound unless @team end def set_match - @match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:id]) + team_ids = current_user.streamable_teams.map(&:id) + @match = Match.where(team_id: team_ids).find(params[:id]) end def match_params diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index bfb6d61..e5483a6 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -4,7 +4,8 @@ module Api before_action :set_session, except: :create def create - match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:match_id]) + team_ids = current_user.streamable_teams.map(&:id) + match = Match.where(team_id: team_ids).find(params[:match_id]) session = Sessions::Create.new(user: current_user, match: match, params: session_params).call render json: session_json(session), status: :created end @@ -66,6 +67,15 @@ module Api } end + def regia_link + access = Sessions::RegiaAccess.new(@session) + token = access.issue_token! + render json: { + regia_url: access.url(token), + expires_at: @session.regia_token_expires_at + } + end + def claim_pairing token = params.require(:pairing_token) digest = Digest::SHA256.hexdigest(token) @@ -101,8 +111,9 @@ module Api private def set_session - @session = StreamSession.joins(match: :team) - .merge(current_user.manageable_teams) + team_ids = current_user.streamable_teams.map(&:id) + @session = StreamSession.joins(:match) + .where(matches: { team_id: team_ids }) .find(params[:id]) end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index fab2c89..39d1c27 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -2,7 +2,7 @@ module Api module V1 class TeamsController < BaseController def index - teams = current_user.manageable_teams.includes(:club, :matches) + teams = current_user.streamable_teams render json: teams.map { |t| team_json(t) } end @@ -27,7 +27,8 @@ module Api end def recordings - team = current_user.manageable_teams.find(params[:id]) + team = current_user.streamable_teams.find { |t| t.id.to_s == params[:id].to_s } + raise ActiveRecord::RecordNotFound unless team unless team.entitlements.can_access_recordings? return render json: { error: "Archivio gare disponibile con Premium Light o Full", @@ -89,16 +90,16 @@ module Api staff_manage_url: ent.staff_manage_url, max_staff: ent.max_staff, max_staff_transmission: ent.max_staff_transmission, - max_staff_regia: ent.max_staff_regia, staff_used: ent.staff_count, staff_transmission_used: ent.staff_count_for("transmission"), - staff_regia_used: ent.staff_count_for("regia"), concurrent_streams_used: ent.concurrent_streams_used, concurrent_streams_limit: ent.concurrent_streams_limit, recordings_enabled: ent.can_access_recordings?, phone_download_enabled: ent.phone_download_enabled?, youtube_enabled: ent.youtube_enabled?, - youtube_mode: ent.plan.youtube_mode + youtube_mode: ent.plan.youtube_mode, + staff_role: current_user.staff_role_for(team), + can_stream: current_user.can_stream_for?(team) } if detail data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| diff --git a/backend/app/controllers/public/club_billing_controller.rb b/backend/app/controllers/public/club_billing_controller.rb new file mode 100644 index 0000000..bbe9481 --- /dev/null +++ b/backend/app/controllers/public/club_billing_controller.rb @@ -0,0 +1,70 @@ +module Public + class ClubBillingController < WebBaseController + before_action :require_login! + before_action :set_club + before_action :require_club_owner_for_club! + + def profile + end + + def update_profile + @club.assign_attributes(billing_profile_params) + if @club.save(context: :billing_profile) + redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati." + else + flash.now[:alert] = @club.errors.full_messages.join(", ") + render :profile, status: :unprocessable_entity + end + end + + def sync_payments + unless MatchLiveTv.stripe_enabled? + redirect_to public_club_billing_path(@club), alert: "Stripe non configurato." + return + end + + count = Billing::Stripe::SyncPayments.call(club: @club) + redirect_to public_club_billing_path(@club), notice: "Sincronizzati #{count} pagamenti da Stripe." + rescue ::Stripe::StripeError => e + redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" + end + + def download_invoice + invoice = @club.billing_invoices.find(params[:invoice_id]) + unless invoice.pdf.attached? + redirect_to public_club_billing_path(@club), alert: "PDF fattura non disponibile." + return + end + + redirect_to rails_blob_path(invoice.pdf, disposition: "attachment"), allow_other_host: true + end + + private + + def set_club + @club = current_user.owned_clubs.find(params[:id]) + end + + def require_club_owner_for_club! + require_club_owner!(@club) + end + + def billing_profile_params + params.require(:club).permit( + :billing_entity_type, + :billing_legal_name, + :billing_vat_number, + :billing_fiscal_code, + :billing_email, + :billing_phone, + :billing_address_line, + :billing_city, + :billing_province, + :billing_postal_code, + :billing_country, + :billing_recipient_code, + :billing_pec + ) + end + end +end diff --git a/backend/app/controllers/public/club_matches_controller.rb b/backend/app/controllers/public/club_matches_controller.rb new file mode 100644 index 0000000..804e8f8 --- /dev/null +++ b/backend/app/controllers/public/club_matches_controller.rb @@ -0,0 +1,85 @@ +module Public + class ClubMatchesController < WebBaseController + before_action :require_login! + before_action :set_club + before_action :load_schedulable_teams + before_action :require_any_schedulable_team! + + def new + @match = default_match_for_form + @selected_team = resolve_selected_team + end + + def create + @selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s } + unless @selected_team + @match = default_match_for_form + flash.now[:alert] = "Seleziona una squadra valida." + return render :new, status: :unprocessable_entity + end + + @match = @selected_team.matches.create!( + match_params.merge(sport: @selected_team.sport, sets_to_win: 3) + ) + redirect_to public_live_index_path(club_id: @club.id), + notice: "Partita programmata: #{@selected_team.name} vs #{@match.opponent_name}." + rescue ActiveRecord::RecordInvalid => e + @match = @selected_team.matches.build(match_params) + flash.now[:alert] = e.record.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + + private + + def set_club + @club = Club.find(params[:club_id]) + end + + def load_schedulable_teams + @schedulable_teams = current_user.schedulable_teams_for(@club) + @club_admin = @club.owned_by?(current_user) + end + + def require_any_schedulable_team! + return if @schedulable_teams.any? + + redirect_to public_live_index_path(club_id: @club.id), + alert: "Non hai permessi per programmare partite in questa società." + end + + def resolve_selected_team + if params[:team_id].present? + @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s } + end || @schedulable_teams.first + end + + def default_match_for_form + team = resolve_selected_team + Match.new( + sport: team&.sport || @club.sport, + sets_to_win: 3, + scheduled_at: default_scheduled_at + ) + end + + def match_params + p = params.require(:match).permit(:opponent_name, :location, :scheduled_at) + 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 default_scheduled_at + t = Time.current + 2.hours + Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0) + end + end +end diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index c438272..47e1fbd 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -4,6 +4,7 @@ module Public before_action :require_login! before_action :set_club, only: %i[show edit update billing checkout portal] + before_action :require_billing_profile_for_premium_checkout!, only: :checkout def new redirect_to public_club_path(current_user.primary_club) if current_user.primary_club @@ -42,6 +43,7 @@ module Public def show require_club_owner!(@club) + apply_checkout_flash! @entitlements_team = @club.teams.first @entitlements = @entitlements_team&.entitlements @teams = @club.teams.order(:name) @@ -64,9 +66,13 @@ module Public def billing require_club_owner!(@club) + apply_checkout_flash! @team = @club.teams.first! @entitlements = @team.entitlements + @subscription = @club.subscription @plans = Plan.ordered + @payments = @club.billing_payments.recent.limit(50) + @invoices = @club.billing_invoices.recent.limit(50) end def checkout @@ -77,8 +83,20 @@ module Public end plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light" - url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url - redirect_to url, allow_other_host: true + target_plan = Plan[plan_slug] + sub = @club.subscription + + if sub&.stripe_subscription_id.present? && sub.active? && sub.plan.slug != plan_slug + Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug) + redirect_to public_club_billing_path(@club), + notice: "Piano aggiornato a #{target_plan.name}. L'eventuale differenza verrà addebitata o accreditata da Stripe." + else + url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url + redirect_to url, allow_other_host: true + end + rescue ::Stripe::StripeError => e + Rails.logger.warn("[Stripe checkout] #{e.message}") + redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" end def portal @@ -99,10 +117,34 @@ module Public end def club_params - p = params.require(:club).permit(:name, :sport, :logo_url, :primary_color, :secondary_color) + p = params.require(:club).permit( + :name, :sport, :logo_url, :primary_color, :secondary_color, + :billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code, + :billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province, + :billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec + ) p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935") p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff") p end + + def require_billing_profile_for_premium_checkout! + require_club_owner!(@club) + plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) + return if plan_slug.blank? + return if @club.billing_profile_complete? + + redirect_to public_club_billing_profile_path(@club), + alert: "Completa i dati di fatturazione prima di attivare un piano premium." + end + + def apply_checkout_flash! + case params[:checkout] + when "success" + flash.now[:notice] = "Pagamento completato! Il piano premium è attivo per la società." + when "canceled" + flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato." + end + end end end diff --git a/backend/app/controllers/public/invitations_controller.rb b/backend/app/controllers/public/invitations_controller.rb index 5710a1e..066d5c3 100644 --- a/backend/app/controllers/public/invitations_controller.rb +++ b/backend/app/controllers/public/invitations_controller.rb @@ -18,7 +18,7 @@ module Public return end invitation.accept!(current_user) - redirect_to public_team_dashboard_path(invitation.team), notice: "Sei entrato nella squadra!" + redirect_to public_team_details_path(invitation.team), notice: "Sei entrato nella squadra!" else session[:pending_invite_token] = params[:token] redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito" diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index 058f9ce..ab37b0e 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -6,33 +6,46 @@ module Public def index @query = params[:q].to_s.strip - @sessions = StreamSession + @club = Club.find_by(id: params[:club_id]) if params[:club_id].present? + team_ids = @club&.teams&.pluck(:id) + + sessions = StreamSession .broadcasting - .includes(:score_state, match: :team) + .includes(:score_state, match: { team: :club }) + sessions = sessions.joins(:match).where(matches: { team_id: team_ids }) if team_ids.present? + + @sessions = sessions .search_by_team_or_opponent(@query) .order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc) - busy_match_ids = StreamSession.where.not(status: %w[ended error]).select(:match_id) - @upcoming_matches = Match - .scheduled_upcoming - .includes(:team) + # Solo dirette già in onda: le partite «programmate» restano visibili anche se c’è sessione idle. + broadcasting_match_ids = StreamSession.broadcasting.select(:match_id) + upcoming = Match.scheduled_for_live.includes(team: :club) + upcoming = upcoming.where(team_id: team_ids) if team_ids.present? + + @upcoming_matches = upcoming .search_teams_or_opponents(@query) - .where.not(id: busy_match_ids) + .where.not(id: broadcasting_match_ids) .order(scheduled_at: :asc) - .limit(30) + .limit(50) @online_paths = Mediamtx::Client.new.online_path_names + + return unless logged_in? && @club + + @schedulable_teams = current_user.schedulable_teams_for(@club) + @can_schedule_match = @schedulable_teams.any? end def show - @session = StreamSession.includes(match: :team).find(params[:id]) + @session = StreamSession.includes(match: { team: :club }).find(params[:id]) @match = @session.match @stream_closed = @session.terminal? @on_air = !@stream_closed && mediamtx_online?(@session) end def status - session = StreamSession.includes(match: :team).find(params[:id]) + session = StreamSession.includes(match: { team: :club }).find(params[:id]) closed = session.terminal? render json: { status: session.status, diff --git a/backend/app/controllers/public/matches_controller.rb b/backend/app/controllers/public/matches_controller.rb new file mode 100644 index 0000000..c271085 --- /dev/null +++ b/backend/app/controllers/public/matches_controller.rb @@ -0,0 +1,102 @@ +module Public + class MatchesController < WebBaseController + before_action :require_login! + before_action :set_team + before_action :require_can_schedule! + before_action :set_match, only: %i[edit update destroy] + + def index + @matches = @team.matches + .includes(:stream_sessions) + .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) + @can_manage = @team.club.owned_by?(current_user) + end + + def new + @match = @team.matches.build( + sport: @team.sport, + sets_to_win: 3, + scheduled_at: default_scheduled_at + ) + end + + def create + @match = @team.matches.create!( + match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3) + ) + redirect_to public_team_matches_path(@team), + notice: "Partita programmata: #{@match.opponent_name}. Avvia lo streaming dall’app quando sei in palestra." + rescue ActiveRecord::RecordInvalid => e + @match = @team.matches.build(match_params) + flash.now[:alert] = e.record.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + + def edit + end + + def update + @match.update!(match_params) + redirect_to public_team_matches_path(@team), notice: "Partita aggiornata." + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = e.record.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + + def destroy + active = active_session_for(@match) + if active&.resumable? + redirect_to public_team_matches_path(@team), + alert: "Chiudi la diretta prima di eliminare questa partita." + return + end + + @match.destroy! + redirect_to public_team_matches_path(@team), notice: "Partita eliminata." + end + + private + + def set_team + @team = current_user.manageable_teams.find(params[:team_id]) + @club = @team.club + end + + def require_can_schedule! + return if current_user.can_schedule_for?(@team) + + redirect_to public_team_details_path(@team), + alert: "Solo un responsabile trasmissione o il titolare della società possono programmare le partite." + end + + def set_match + @match = @team.matches.find(params[:id]) + end + + def match_params + p = params.require(:match).permit(:opponent_name, :location, :scheduled_at, :sport, :sets_to_win) + 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 default_scheduled_at + t = Time.current + 2.hours + Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0) + end + + def active_session_for(match) + match.stream_sessions + .order(created_at: :desc) + .find { |s| !%w[ended error].include?(s.status) } + end + end +end diff --git a/backend/app/controllers/public/pages_controller.rb b/backend/app/controllers/public/pages_controller.rb index 218151b..24891ff 100644 --- a/backend/app/controllers/public/pages_controller.rb +++ b/backend/app/controllers/public/pages_controller.rb @@ -8,6 +8,20 @@ module Public def pricing @plans = Plan.ordered + load_club_billing_context_for_pricing + end + + private + + def load_club_billing_context_for_pricing + return unless logged_in? + + @club = current_user.primary_club + return unless @club + + @subscription = @club.subscription + @team = @club.teams.order(:name).first + @entitlements = @team&.entitlements end def privacy diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb new file mode 100644 index 0000000..8c91c88 --- /dev/null +++ b/backend/app/controllers/public/regia_controller.rb @@ -0,0 +1,82 @@ +module Public + class RegiaController < SiteBaseController + include Public::LiveHelper + + layout "regia" + + protect_from_forgery with: :null_session + skip_before_action :verify_authenticity_token, only: %i[score pause stop] + + before_action :set_session_from_token + + def show + @match = @session.match + @team = @match.team + @score = @session.score_state || @session.create_score_state!( + home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: [] + ) + @rules = Scoring::Rules.from_match(@match) + @stream_closed = @session.terminal? + @on_air = !@stream_closed && mediamtx_online?(@session) + @share_url = request.original_url + @share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" + end + + def status + render json: status_payload + end + + def score + cmd = params[:cmd].presence || params[:score_action].presence + result = Scoring::ApplyAction.new(session: @session, action: cmd).call + render json: status_payload.merge( + set_won: result.set_won, + match_won: result.match_won, + winner: result.winner&.to_s + ) + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def pause + Sessions::Pause.new(@session).call + render json: status_payload + end + + def stop + Sessions::Stop.new(@session).call + render json: status_payload + end + + private + + def set_session_from_token + @session = Sessions::RegiaAccess.find_session_by_token(params[:token]) + return if @session + + render file: Rails.root.join("public/404.html"), status: :not_found, layout: false + throw :abort + end + + def status_payload + score = @session.score_state + closed = @session.terminal? + { + status: @session.status, + stream_closed: closed, + live: !closed && (@session.live? || mediamtx_online?(@session)), + on_air: !closed && mediamtx_online?(@session), + ended_at: @session.ended_at, + home_name: @session.match.team.name, + away_name: @session.match.opponent_name, + points_target: Scoring::Rules.from_match(@session.match).points_target(score&.current_set || 1), + score: score&.as_cable_payload + } + end + + def mediamtx_online?(session) + @online_paths_cache ||= Mediamtx::Client.new.online_path_names + @online_paths_cache.include?(session.mediamtx_path_name) + end + end +end diff --git a/backend/app/controllers/public/registrations_controller.rb b/backend/app/controllers/public/registrations_controller.rb index d6c0c93..76cf638 100644 --- a/backend/app/controllers/public/registrations_controller.rb +++ b/backend/app/controllers/public/registrations_controller.rb @@ -20,7 +20,7 @@ module Public invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) if invitation && invitation.email.downcase == @user.email.downcase invitation.accept!(@user) - return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!" + return redirect_to public_team_details_path(invitation.team), notice: "Benvenuto nella squadra!" end end redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società." diff --git a/backend/app/controllers/public/sessions_controller.rb b/backend/app/controllers/public/sessions_controller.rb index 8240ef6..5c31cbc 100644 --- a/backend/app/controllers/public/sessions_controller.rb +++ b/backend/app/controllers/public/sessions_controller.rb @@ -13,7 +13,7 @@ module Public dest = if user.primary_club public_club_path(user.primary_club) elsif user.manageable_teams.first - public_team_dashboard_path(user.manageable_teams.first) + public_team_details_path(user.manageable_teams.first) else public_new_club_path end diff --git a/backend/app/controllers/public/team_roster_members_controller.rb b/backend/app/controllers/public/team_roster_members_controller.rb new file mode 100644 index 0000000..f87a902 --- /dev/null +++ b/backend/app/controllers/public/team_roster_members_controller.rb @@ -0,0 +1,63 @@ +module Public + class TeamRosterMembersController < WebBaseController + before_action :require_login! + before_action :set_team + before_action :require_team_owner! + before_action :set_member, only: %i[edit update destroy] + + def create + member = @team.roster_members.build(member_params) + attach_photo(member) + member.save! + redirect_to public_team_details_path(@team), notice: "#{member.full_name} aggiunto all'organico." + rescue ActiveRecord::RecordInvalid => e + redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ") + end + + def edit + @club = @team.club + end + + def update + @member.assign_attributes(member_params) + attach_photo(@member) + @member.save! + redirect_to public_team_details_path(@team), notice: "Scheda aggiornata." + rescue ActiveRecord::RecordInvalid => e + @club = @team.club + flash.now[:alert] = e.record.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + + def destroy + name = @member.full_name + @member.destroy! + redirect_to public_team_details_path(@team), notice: "#{name} rimosso dall'organico." + end + + private + + def set_team + @team = current_user.manageable_teams.find(params[:team_id]) + end + + def require_team_owner! + require_club_owner_for_team!(@team) + end + + def set_member + @member = @team.roster_members.find(params[:id]) + end + + def member_params + params.require(:team_roster_member).permit( + :category, :full_name, :role_label, :jersey_number, :bio, :position + ) + end + + def attach_photo(member) + file = params.dig(:team_roster_member, :photo_file) + member.photo_file.attach(file) if file.present? + end + end +end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 49a0127..4969703 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -5,7 +5,7 @@ module Public before_action :require_login! before_action :set_club, only: %i[new create] before_action :set_team, only: %i[ - dashboard edit update invite create_invitation + details dashboard roster edit update invite create_invitation assign_self_staff clear_self_staff remove_member destroy_invitation ] @@ -24,15 +24,17 @@ module Public render :new, status: :unprocessable_entity end + def details + load_team_details! + render :details + end + def dashboard - require_club_owner_for_team!(@team) - @club = @team.club - @entitlements = @team.entitlements - @recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10) - @owner_membership = current_user.user_teams.find_by(team: @team) - @staff_memberships = @team.user_teams.includes(:user) - .where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)") - @pending_invitations = @team.team_invitations.pending.order(created_at: :desc) + redirect_to public_team_details_path(params[:id]) + end + + def roster + redirect_to public_team_details_path(params[:id]) end def edit @@ -44,8 +46,9 @@ module Public require_club_owner_for_team!(@team) @team.assign_attributes(team_params) attach_branding_logo(@team) + attach_team_photo(@team) @team.save! - redirect_to public_team_dashboard_path(@team), notice: "Squadra aggiornata." + redirect_to public_team_details_path(@team), notice: "Squadra aggiornata." rescue ActiveRecord::RecordInvalid => e @club = @team.club flash.now[:alert] = e.record.errors.full_messages.join(", ") @@ -59,27 +62,25 @@ module Public def assign_self_staff require_club_owner_for_team!(@team) - staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission" membership = current_user.user_teams.find_or_initialize_by(team: @team) membership.role = "member" if membership.new_record? - Teams::StaffAssignment.call(team: @team, user: current_user, staff_kind: staff_kind, membership: membership) - label = staff_kind == "regia" ? "Regia" : "Trasmissione" - redirect_to public_team_dashboard_path(@team), notice: "Il tuo account è ora staff #{label}." + Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership) + redirect_to public_team_details_path(@team), notice: "Il tuo account è ora responsabile trasmissione." rescue Teams::StaffAssignmentError, Teams::EntitlementError => e - redirect_to public_team_dashboard_path(@team), alert: e.message + redirect_to public_team_details_path(@team), alert: e.message end def clear_self_staff require_club_owner_for_team!(@team) membership = current_user.user_teams.find_by(team: @team) membership&.update!(staff_kind: nil) - redirect_to public_team_dashboard_path(@team), notice: "Ruolo staff rimosso dal tuo account." + redirect_to public_team_details_path(@team), notice: "Ruolo staff rimosso dal tuo account." end def create_invitation require_club_owner_for_team!(@team) @entitlements = @team.entitlements - staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission" + staff_kind = "transmission" @entitlements.assert_can_invite!(staff_kind: staff_kind) email = params[:email]&.downcase&.strip @@ -103,18 +104,35 @@ module Public require_club_owner_for_team!(@team) ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member") ut.destroy! - redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato" + redirect_to public_team_details_path(@team), notice: "Accesso revocato" end def destroy_invitation require_club_owner_for_team!(@team) inv = @team.team_invitations.pending.find(params[:invitation_id]) inv.destroy! - redirect_to public_team_dashboard_path(@team), notice: "Invito annullato" + redirect_to public_team_details_path(@team), notice: "Invito annullato" end private + def load_team_details! + @club = @team.club + @can_manage = @club.owned_by?(current_user) + @entitlements = @team.entitlements + @roster_by_category = @team.roster_by_category + if @can_manage + @roster_member = @team.roster_members.build( + category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player" + ) + end + @recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10) + @owner_membership = current_user.user_teams.find_by(team: @team) + @staff_memberships = @team.user_teams.includes(:user) + .where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)") + @pending_invitations = @team.team_invitations.pending.order(created_at: :desc) + end + def set_club @club = current_user.owned_clubs.find(params[:club_id]) end @@ -123,8 +141,13 @@ module Public @team = current_user.manageable_teams.find(params[:id]) end + def attach_team_photo(team) + file = params.dig(:team, :photo_file) + team.photo_file.attach(file) if file.present? + end + def team_params - p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color) + p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color) p[:primary_color] = p[:primary_color].presence p[:secondary_color] = p[:secondary_color].presence if p[:primary_color].present? diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb new file mode 100644 index 0000000..09200ed --- /dev/null +++ b/backend/app/helpers/public/billing_helper.rb @@ -0,0 +1,29 @@ +module Public + module BillingHelper + def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:) + return { kind: :current, label: "Piano attuale" } if current_slug == target_plan.slug + + if target_plan.slug == "free" + return { kind: :contact, label: "Contatta il supporto per downgrade." } + end + + unless MatchLiveTv.stripe_enabled? + return { kind: :disabled, label: "Stripe non configurato" } + end + + if current_slug == "free" || !stripe_subscription_active + return { kind: :checkout, label: "Attiva #{target_plan.name}" } + end + + if Plan.tier(target_plan.slug) > Plan.tier(current_slug) + { kind: :change, label: "Passa a #{target_plan.name}" } + else + { kind: :change, label: "Passa a #{target_plan.name}" } + end + end + + def stripe_subscription_active?(subscription) + subscription&.stripe_subscription_id.present? && subscription.active? + end + end +end diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index 0e8ccd4..e5e50e5 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -33,6 +33,28 @@ module Public end end + def live_match_card_heading(match) + team = match.team + club_name = team.club&.name.presence || "Società" + content_tag(:h3, class: "live-card__title") do + safe_join([ + content_tag(:span, club_name, class: "live-card__club"), + content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup") + ]) + end + end + + def live_match_page_heading(match) + team = match.team + club_name = team.club&.name.presence || "Società" + content_tag(:div, class: "live-page-heading") do + safe_join([ + content_tag(:p, club_name, class: "live-page-heading__club"), + content_tag(:h1, "#{team.name} vs #{match.opponent_name}", class: "live-page-heading__matchup") + ]) + end + end + def live_score_points_label(match, score_state) return "—" unless score_state diff --git a/backend/app/helpers/roster_helper.rb b/backend/app/helpers/roster_helper.rb new file mode 100644 index 0000000..804b4b2 --- /dev/null +++ b/backend/app/helpers/roster_helper.rb @@ -0,0 +1,32 @@ +module RosterHelper + def roster_person_avatar(person, team, size: 96) + primary = team.effective_primary_color + secondary = team.effective_secondary_color + style = "--avatar-primary:#{primary};--avatar-secondary:#{secondary};width:#{size}px;height:#{size}px" + + if person.photo_url.present? + image_tag person.photo_url, alt: person.full_name, class: "roster-avatar roster-avatar--photo", + style: style, width: size, height: size + else + tag.div( + person.initials, + class: "roster-avatar roster-avatar--placeholder", + style: "#{style};font-size:#{(size * 0.34).round}px", + aria: { label: person.full_name } + ) + end + end + + def roster_category_options + TeamRosterMember::DISPLAY_ORDER.map do |cat| + [TeamRosterMember::CATEGORY_LABELS[cat], cat] + end + end + + def roster_player_role_options(selected = nil) + options_for_select( + [["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] }, + selected + ) + end +end diff --git a/backend/app/helpers/staff_helper.rb b/backend/app/helpers/staff_helper.rb new file mode 100644 index 0000000..a777f79 --- /dev/null +++ b/backend/app/helpers/staff_helper.rb @@ -0,0 +1,9 @@ +module StaffHelper + def staff_role_label(membership, team) + Teams::StaffCoverage.new(team).role_label_for(membership) + end + + def staff_coverage_for(team) + Teams::StaffCoverage.new(team) + end +end diff --git a/backend/app/models/billing/invoice.rb b/backend/app/models/billing/invoice.rb new file mode 100644 index 0000000..2d53468 --- /dev/null +++ b/backend/app/models/billing/invoice.rb @@ -0,0 +1,43 @@ +module Billing + class Invoice < ApplicationRecord + self.table_name = "billing_invoices" + + STATUSES = %w[draft issued sent cancelled].freeze + SOURCES = %w[aruba manual].freeze + + belongs_to :club + belongs_to :billing_payment, class_name: "Billing::Payment", optional: true + has_one_attached :pdf + + validates :number, presence: true, uniqueness: { scope: :club_id } + validates :issued_on, presence: true + validates :amount_cents, numericality: { greater_than: 0 } + validates :currency, presence: true + validates :status, inclusion: { in: STATUSES } + validates :source, inclusion: { in: SOURCES } + validate :pdf_present_when_issued + + scope :recent, -> { order(issued_on: :desc, created_at: :desc) } + + def amount_euros + amount_cents / 100.0 + end + + def formatted_amount + format("%.2f €", amount_euros) + end + + def downloadable? + pdf.attached? + end + + private + + def pdf_present_when_issued + return unless status.in?(%w[issued sent]) + return if pdf.attached? + + errors.add(:pdf, "è obbligatorio per le fatture emesse") + end + end +end diff --git a/backend/app/models/billing/payment.rb b/backend/app/models/billing/payment.rb new file mode 100644 index 0000000..68fe8e1 --- /dev/null +++ b/backend/app/models/billing/payment.rb @@ -0,0 +1,25 @@ +module Billing + class Payment < ApplicationRecord + self.table_name = "billing_payments" + + STATUSES = %w[paid failed refunded].freeze + + belongs_to :club + has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify + + validates :amount_cents, numericality: { greater_than: 0 } + validates :currency, presence: true + validates :status, inclusion: { in: STATUSES } + validates :stripe_invoice_id, uniqueness: true, allow_nil: true + + scope :recent, -> { order(paid_at: :desc, created_at: :desc) } + + def amount_euros + amount_cents / 100.0 + end + + def formatted_amount + format("%.2f €", amount_euros) + end + end +end diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index 198b60d..e5c115d 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -1,10 +1,13 @@ class Club < ApplicationRecord include Brandable + include ClubBillingProfile has_many :club_memberships, dependent: :destroy has_many :users, through: :club_memberships has_many :teams, dependent: :destroy has_one :subscription, dependent: :destroy + has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy + has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy validates :name, presence: true validates :sport, presence: true diff --git a/backend/app/models/concerns/club_billing_profile.rb b/backend/app/models/concerns/club_billing_profile.rb new file mode 100644 index 0000000..4cb09f5 --- /dev/null +++ b/backend/app/models/concerns/club_billing_profile.rb @@ -0,0 +1,47 @@ +module ClubBillingProfile + extend ActiveSupport::Concern + + BILLING_ENTITY_TYPES = { + "company" => "Società / ASD con P.IVA", + "individual" => "Persona fisica", + "nonprofit" => "Associazione senza scopo di lucro" + }.freeze + + included do + validates :billing_entity_type, inclusion: { in: BILLING_ENTITY_TYPES.keys }, allow_nil: true + validates :billing_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true + validates :billing_recipient_code, length: { is: 7 }, allow_blank: true + validates :billing_province, length: { is: 2 }, allow_blank: true + validates :billing_country, length: { is: 2 }, allow_blank: true + validate :billing_profile_for_invoicing, on: :billing_profile + end + + def billing_profile_complete? + billing_profile_errors.empty? + end + + def billing_profile_errors + errors = [] + errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank? + errors << "Email di fatturazione" if billing_email.blank? + errors << "Indirizzo" if billing_address_line.blank? + errors << "Città" if billing_city.blank? + errors << "CAP" if billing_postal_code.blank? + errors << "P.IVA o Codice Fiscale" if billing_vat_number.blank? && billing_fiscal_code.blank? + errors << "Codice destinatario SDI o PEC" if billing_recipient_code.blank? && billing_pec.blank? + errors + end + + def billing_profile_summary + parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"] + parts.compact.join(" · ") + end + + private + + def billing_profile_for_invoicing + billing_profile_errors.each do |message| + errors.add(:base, message) + end + end +end diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index b67cb52..60f6049 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -7,17 +7,47 @@ class Match < ApplicationRecord validates :opponent_name, presence: true validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } - scope :scheduled_upcoming, -> { - where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.current) + # Partite con data/ora: da inizio giornata (Europe/Rome) in poi — allineato alla pagina Partite. + scope :scheduled_for_live, -> { + where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now.beginning_of_day) } + scope :scheduled_upcoming, -> { scheduled_for_live } + + def scheduled_upcoming? + scheduled_at.present? && scheduled_at >= Time.zone.now + end + + def scheduled_on_calendar? + scheduled_at.present? && scheduled_at >= Time.zone.now.beginning_of_day + end + + def active_stream_session + stream_sessions.sort_by { |s| s.created_at }.reverse + .find { |s| !%w[ended error].include?(s.status) } + end + + def deletable? + session = active_stream_session + session.nil? || !session.resumable? + end + + def public_status_label + active = active_stream_session + return "In corso / da riprendere" if active + return "Programmata" if scheduled_upcoming? + return "Pronta" if scheduled_at.present? + + "Senza orario" + end + scope :search_teams_or_opponents, lambda { |query| q = query.to_s.strip return all if q.blank? term = "%#{sanitize_sql_like(q)}%" - joins(:team).where( - "teams.name ILIKE :term OR matches.opponent_name ILIKE :term", + joins(team: :club).where( + "teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term", term: term ) } diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb index 00eb601..74b2045 100644 --- a/backend/app/models/plan.rb +++ b/backend/app/models/plan.rb @@ -16,6 +16,14 @@ class Plan < ApplicationRecord find_by!(slug: slug.to_s) end + def self.tier(slug) + SLUGS.index(slug.to_s) || 0 + end + + def tier + self.class.tier(slug) + end + def allows_platform?(platform) return false if platform.to_s == "youtube" && !youtube_enabled? diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 6b17f40..b8d9a96 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -21,8 +21,8 @@ class StreamSession < ApplicationRecord return all if q.blank? term = "%#{sanitize_sql_like(q)}%" - joins(match: :team).where( - "teams.name ILIKE :term OR matches.opponent_name ILIKE :term", + joins(match: { team: :club }).where( + "teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term", term: term ) } diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 0c0dce8..814263f 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -8,8 +8,12 @@ class Team < ApplicationRecord has_one :youtube_credential, dependent: :destroy has_many :recordings, dependent: :destroy has_many :team_invitations, dependent: :destroy + has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy + + has_one_attached :photo_file validates :name, presence: true + validate :photo_file_type, if: -> { photo_file.attached? } def subscription club.subscription @@ -23,8 +27,26 @@ class Team < ApplicationRecord club&.owner end + def team_photo_url + if photo_file.attached? + Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true) + end + end + + def roster_by_category + TeamRosterMember::DISPLAY_ORDER.index_with do |cat| + roster_members.by_category(cat).to_a + end + end + private + def photo_file_type + return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp]) + + errors.add(:photo_file, "deve essere PNG, JPEG o WebP") + end + def branding_parent club end diff --git a/backend/app/models/team_invitation.rb b/backend/app/models/team_invitation.rb index 846c5b0..b09ca3e 100644 --- a/backend/app/models/team_invitation.rb +++ b/backend/app/models/team_invitation.rb @@ -1,5 +1,5 @@ class TeamInvitation < ApplicationRecord - STAFF_KINDS = %w[transmission regia].freeze + STAFF_KINDS = %w[transmission].freeze belongs_to :team diff --git a/backend/app/models/team_roster_member.rb b/backend/app/models/team_roster_member.rb new file mode 100644 index 0000000..3b358ca --- /dev/null +++ b/backend/app/models/team_roster_member.rb @@ -0,0 +1,87 @@ +class TeamRosterMember < ApplicationRecord + CATEGORIES = %w[staff coach manager player].freeze + # Ordine di visualizzazione in pagina organico + DISPLAY_ORDER = %w[coach manager player staff].freeze + CATEGORY_LABELS = { + "staff" => "Staff", + "coach" => "Allenatori", + "manager" => "Dirigenti", + "player" => "Giocatori" + }.freeze + + VOLLEYBALL_PLAYER_ROLES = %w[ + Schiacciatore + Opposto + Centrale + Palleggiatore + Libero + Universale + ].freeze + + belongs_to :team + + has_one_attached :photo_file + + validates :full_name, presence: true + validates :category, inclusion: { in: CATEGORIES } + validates :jersey_number, numericality: { only_integer: true, greater_than: 0, less_than: 100 }, allow_nil: true + validate :photo_file_type, if: -> { photo_file.attached? } + validate :player_role_label_valid, if: -> { category == "player" && role_label.present? } + + before_validation :assign_position, on: :create + + scope :ordered, -> { order(:position, :created_at) } + scope :by_category, ->(cat) { where(category: cat).ordered } + + def category_label + CATEGORY_LABELS[category] || category + end + + def initials + parts = full_name.to_s.split(/\s+/).reject(&:blank?) + return "?" if parts.empty? + + parts.first(2).map { |p| p[0] }.join.upcase + end + + def photo_url + return unless photo_file.attached? + + Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true) + end + + def display_role + role_label.presence || default_role_label + end + + private + + def assign_position + return if position.positive? || team.blank? + + max_pos = team.roster_members.where(category: category).maximum(:position) || -1 + self.position = max_pos + 1 + end + + def default_role_label + case category + when "coach" then "Allenatore" + when "manager" then "Dirigente" + when "staff" then "Staff" + when "player" then jersey_number.present? ? "Giocatore ##{jersey_number}" : "Giocatore" + else category + end + end + + def player_role_label_valid + return if role_label.in?(VOLLEYBALL_PLAYER_ROLES) + + errors.add(:role_label, "non è un ruolo pallavolo valido") + end + + def photo_file_type + return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp]) + + errors.add(:photo_file, "deve essere PNG, JPEG o WebP") + end +end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 6c0180f..9e26314 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -16,6 +16,51 @@ class User < ApplicationRecord Team.where(id: staff_ids).or(Team.where(id: owner_ids)) end + # Squadre da cui l'utente può programmare partite e avviare lo streaming (app). + def streamable_teams + manageable_teams.includes(:club).select { |team| can_stream_for?(team) } + end + + def club_admin?(club) + club&.owned_by?(self) + end + + def can_schedule_for?(team) + return true if team.club&.owned_by?(self) + + membership = user_teams.find_by(team: team) + return false if membership&.staff_kind.blank? + + membership.staff_kind == "transmission" + end + + def schedulable_teams_for(club) + teams = club.teams.order(:name).to_a + return teams if club.owned_by?(self) + + teams.select { |team| can_schedule_for?(team) } + end + + def can_stream_for?(team) + return true if team.club&.owned_by?(self) + + membership = user_teams.find_by(team: team) + return false if membership&.staff_kind.blank? + + return true if membership.staff_kind == "transmission" + + Teams::StaffCoverage.new(team).covers_both_roles?(membership) + end + + def staff_role_for(team) + return "owner" if team.club&.owned_by?(self) + + membership = user_teams.find_by(team: team) + return nil unless membership&.staff_kind.present? + + "transmission" + end + def primary_club owned_clubs.order(:created_at).first end diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb new file mode 100644 index 0000000..c4c99b5 --- /dev/null +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -0,0 +1,76 @@ +module Billing + module Stripe + class ChangePlan + VALID_PLANS = CheckoutSession::VALID_PLANS.freeze + + def self.call(club:, plan_slug:) + new(club: club, plan_slug: plan_slug).call + end + + def initialize(club:, plan_slug:) + @club = club + @plan_slug = plan_slug.to_s + end + + def call + raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled? + raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug) + + sub = @club.subscription + raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank? + raise "Sei già su questo piano" if sub.plan.slug == @plan_slug + + price_id = Plan[@plan_slug].stripe_price_id.presence || stripe_price_id_for(@plan_slug) + raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank? + + stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id) + item_id = stripe_sub.items.data.first.id + + updated = ::Stripe::Subscription.update( + stripe_sub.id, + items: [{ id: item_id, price: price_id }], + metadata: { club_id: @club.id, plan_slug: @plan_slug }, + proration_behavior: "create_prorations" + ) + + apply_local_plan!(updated) + updated + end + + private + + def stripe_price_id_for(slug) + case slug + when "premium_light" then MatchLiveTv.stripe_premium_light_price_id + when "premium_full" then MatchLiveTv.stripe_premium_full_price_id + end + end + + def apply_local_plan!(stripe_sub) + period_start, period_end = SubscriptionPeriod.times(stripe_sub) + Billing::AssignPlan.call( + club: @club, + plan_slug: @plan_slug, + status: map_status(stripe_sub.status), + stripe_attrs: { + stripe_customer_id: stripe_sub.customer, + stripe_subscription_id: stripe_sub.id, + current_period_start: period_start, + current_period_end: period_end, + cancel_at_period_end: stripe_sub.cancel_at_period_end + } + ) + end + + def map_status(stripe_status) + case stripe_status + when "trialing" then "trialing" + when "active" then "active" + when "past_due", "unpaid" then "past_due" + when "canceled", "incomplete_expired" then "canceled" + else "incomplete" + end + end + end + end +end diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb index 9a257df..1ee4fb3 100644 --- a/backend/app/services/billing/stripe/checkout_session.rb +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -64,11 +64,15 @@ module Billing end def success_url - "#{dashboard_url}?checkout=success" + "#{billing_url}?checkout=success" end def cancel_url - "#{dashboard_url}?checkout=canceled" + "#{billing_url}?checkout=canceled" + end + + def billing_url + "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing" end def dashboard_url diff --git a/backend/app/services/billing/stripe/record_payment.rb b/backend/app/services/billing/stripe/record_payment.rb new file mode 100644 index 0000000..0c2e4ac --- /dev/null +++ b/backend/app/services/billing/stripe/record_payment.rb @@ -0,0 +1,90 @@ +module Billing + module Stripe + class RecordPayment + def self.call(stripe_invoice:, club: nil) + new(stripe_invoice: stripe_invoice, club: club).call + end + + def initialize(stripe_invoice:, club: nil) + @stripe_invoice = stripe_invoice + @club = club + end + + def call + @club ||= resolve_club + return unless @club + + payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id) + payment.assign_attributes( + club: @club, + stripe_payment_intent_id: payment_intent_id, + stripe_charge_id: charge_id, + amount_cents: @stripe_invoice.amount_paid, + currency: @stripe_invoice.currency, + status: map_status(@stripe_invoice.status), + plan_slug: plan_slug, + description: description, + paid_at: paid_at, + invoice_pdf_url: @stripe_invoice.invoice_pdf, + receipt_url: @stripe_invoice.hosted_invoice_url + ) + payment.save! + payment + end + + private + + def resolve_club + club_id = metadata_club_id(@stripe_invoice) + return Club.find_by(id: club_id) if club_id.present? + + sub_id = @stripe_invoice.subscription + return unless sub_id.present? + + Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id }) + end + + def metadata_club_id(obj) + meta = obj.metadata + return nil if meta.blank? + + meta["club_id"] || meta[:club_id] + end + + def plan_slug + meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata + meta["plan_slug"] || meta[:plan_slug] + end + + def description + lines = @stripe_invoice.lines&.data + return @stripe_invoice.description if lines.blank? + + lines.first.description.presence || @stripe_invoice.description + end + + def payment_intent_id + pi = @stripe_invoice.payment_intent + pi.is_a?(String) ? pi : pi&.id + end + + def charge_id + charge = @stripe_invoice.charge + charge.is_a?(String) ? charge : charge&.id + end + + def paid_at + ts = @stripe_invoice.status_transitions&.paid_at + ts ? Time.zone.at(ts) : Time.current + end + + def map_status(stripe_status) + case stripe_status + when "paid" then "paid" + when "uncollectible", "void" then "failed" + else "failed" + end + end + end + end +end diff --git a/backend/app/services/billing/stripe/subscription_period.rb b/backend/app/services/billing/stripe/subscription_period.rb new file mode 100644 index 0000000..57879c0 --- /dev/null +++ b/backend/app/services/billing/stripe/subscription_period.rb @@ -0,0 +1,27 @@ +module Billing + module Stripe + module SubscriptionPeriod + module_function + + def times(stripe_sub) + item = stripe_sub.items&.data&.first + start_at = read_value(stripe_sub, :current_period_start) || read_value(item, :current_period_start) + end_at = read_value(stripe_sub, :current_period_end) || read_value(item, :current_period_end) + [unix_time(start_at), unix_time(end_at)] + end + + def read_value(object, field) + return nil if object.blank? + return nil unless object.respond_to?(field) + + object.public_send(field) + end + + def unix_time(value) + return nil if value.blank? + + Time.zone.at(value.to_i) + end + end + end +end diff --git a/backend/app/services/billing/stripe/sync_payments.rb b/backend/app/services/billing/stripe/sync_payments.rb new file mode 100644 index 0000000..be8ede4 --- /dev/null +++ b/backend/app/services/billing/stripe/sync_payments.rb @@ -0,0 +1,36 @@ +module Billing + module Stripe + class SyncPayments + def self.call(club:) + new(club: club).call + end + + def initialize(club:) + @club = club + end + + def call + customer_id = @club.subscription&.stripe_customer_id + return 0 if customer_id.blank? + + count = 0 + starting_after = nil + + loop do + list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after) + list.data.each do |invoice| + next unless invoice.status == "paid" + + RecordPayment.call(stripe_invoice: invoice, club: @club) + count += 1 + end + break unless list.has_more + + starting_after = list.data.last.id + end + + count + end + end + end +end diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb index 36ad5a5..1ffe48f 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -19,6 +19,8 @@ module Billing downgrade_to_free(@event.data.object) when "invoice.payment_failed" mark_past_due(@event.data.object) + when "invoice.paid" + record_payment(@event.data.object) end end @@ -49,8 +51,7 @@ module Billing plan_slug = "premium_full" if plan_slug == "premium" plan = Plan[plan_slug] status = map_status(stripe_sub.status) - period_start = unix_time(stripe_sub.current_period_start) - period_end = unix_time(stripe_sub.current_period_end) + period_start, period_end = SubscriptionPeriod.times(stripe_sub) Billing::AssignPlan.call( club: club, @@ -84,6 +85,10 @@ module Billing ) end + def record_payment(invoice) + Billing::Stripe::RecordPayment.call(stripe_invoice: invoice) + end + def mark_past_due(invoice) return if invoice.subscription.blank? diff --git a/backend/app/services/scoring/apply_action.rb b/backend/app/services/scoring/apply_action.rb new file mode 100644 index 0000000..74a8d21 --- /dev/null +++ b/backend/app/services/scoring/apply_action.rb @@ -0,0 +1,110 @@ +module Scoring + class ApplyAction + Result = Struct.new(:score, :set_won, :match_won, :winner, keyword_init: true) + + def initialize(session:, action:) + @session = session + @match = session.match + @action = action.to_s + @rules = Rules.from_match(@match) + end + + def call + score = @session.score_state || @session.create_score_state!( + home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: [] + ) + + case @action + when "home_point" + apply_point(score, :home) + when "away_point" + apply_point(score, :away) + when "home_undo" + apply_undo(score, :home) + when "away_undo" + apply_undo(score, :away) + when "close_set" + apply_close_set(score) + else + raise ArgumentError, "Azione non valida" + end + end + + private + + def apply_point(score, side) + if side == :home + score.home_points += 1 + else + score.away_points += 1 + end + score.save! + broadcast(score) + winner = @rules.set_winner_from_points( + home_points: score.home_points, + away_points: score.away_points, + current_set: score.current_set + ) + Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner) + end + + def apply_undo(score, side) + if side == :home + score.home_points = [score.home_points - 1, 0].max + else + score.away_points = [score.away_points - 1, 0].max + end + score.save! + broadcast(score) + Result.new(score: score, set_won: false, match_won: false, winner: nil) + end + + def apply_close_set(score) + winner = @rules.set_winner_from_points( + home_points: score.home_points, + away_points: score.away_points, + current_set: score.current_set + ) + winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points + return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner + + close_set_for(score, winner) + end + + def close_set_for(score, winner) + partials = Array(score.set_partials) + partials << { + "set" => score.current_set, + "home" => score.home_points, + "away" => score.away_points + } + + if winner == :home + score.home_sets += 1 + else + score.away_sets += 1 + end + + match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets) + + score.assign_attributes( + set_partials: partials, + home_points: 0, + away_points: 0, + current_set: score.current_set + 1 + ) + score.save! + broadcast(score) + Result.new( + score: score, + set_won: true, + match_won: match_winner.present?, + winner: match_winner || winner + ) + end + + def broadcast(score) + SessionChannel.broadcast_message(@session, score.as_cable_payload) + end + end +end diff --git a/backend/app/services/scoring/rules.rb b/backend/app/services/scoring/rules.rb new file mode 100644 index 0000000..bebaeda --- /dev/null +++ b/backend/app/services/scoring/rules.rb @@ -0,0 +1,46 @@ +module Scoring + class Rules + Side = Struct.new(:home, :away, keyword_init: true) + + def self.from_match(match) + overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {} + new( + sets_to_win: match.sets_to_win, + points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25, + points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15, + min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2 + ) + end + + def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2) + @sets_to_win = sets_to_win + @points_per_set = points_per_set + @points_deciding_set = points_deciding_set + @min_point_lead = min_point_lead + end + + def max_sets + (@sets_to_win * 2) - 1 + end + + def points_target(current_set) + current_set >= max_sets ? @points_deciding_set : @points_per_set + end + + def set_winner_from_points(home_points:, away_points:, current_set:) + target = points_target(current_set) + if home_points >= target && (home_points - away_points) >= @min_point_lead + :home + elsif away_points >= target && (away_points - home_points) >= @min_point_lead + :away + end + end + + def match_winner(home_sets:, away_sets:) + return :home if home_sets >= @sets_to_win + return :away if away_sets >= @sets_to_win + + nil + end + end +end diff --git a/backend/app/services/sessions/regia_access.rb b/backend/app/services/sessions/regia_access.rb new file mode 100644 index 0000000..81a425f --- /dev/null +++ b/backend/app/services/sessions/regia_access.rb @@ -0,0 +1,65 @@ +module Sessions + class RegiaAccess + DEFAULT_TTL = 12.hours + + def initialize(session) + @session = session + end + + # Genera un nuovo link (invalida quello precedente). + def issue_token! + rotate! + end + + def rotate! + token = SecureRandom.urlsafe_base64(24) + expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min + @session.update!( + regia_token_digest: digest(token), + regia_token_expires_at: expires + ) + token + end + + def url(token) + "#{MatchLiveTv.app_public_url.chomp('/')}/regia/#{token}" + end + + def self.find_session_by_token(token) + return if token.blank? + + session = StreamSession.find_by(regia_token_digest: digest(token)) + return unless session + return if session.regia_token_expires_at.blank? || session.regia_token_expires_at.past? + return if session.terminal? + + session + end + + def self.revoke!(session) + session.update!(regia_token_digest: nil, regia_token_expires_at: nil) + end + + def self.digest(token) + Digest::SHA256.hexdigest(token.to_s) + end + + private + + def token_valid? + @session.regia_token_digest.present? && + @session.regia_token_expires_at&.future? && + !@session.terminal? + end + + def session_end_cap + return nil unless @session.started_at + + @session.started_at + 8.hours + end + + def digest(token) + self.class.digest(token) + end + end +end diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb index 6077fcf..9201342 100644 --- a/backend/app/services/sessions/stop.rb +++ b/backend/app/services/sessions/stop.rb @@ -6,6 +6,7 @@ module Sessions def call cancel_timeout_job + Sessions::RegiaAccess.revoke!(@session) @session.end_stream! Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id) Recordings::IndexSession.new(@session).call diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb index 67ed8b8..41b2c61 100644 --- a/backend/app/services/teams/entitlements.rb +++ b/backend/app/services/teams/entitlements.rb @@ -48,11 +48,15 @@ module Teams end def staff_count - staff_count_for("transmission") + staff_count_for("regia") + staff_count_for("transmission") end - def staff_count_for(kind) - members_count_for(kind) + pending_invitations_count_for(kind) + def staff_count_for(_kind = "transmission") + staff_coverage.covered_count + end + + def staff_assigned_for(_kind = "transmission") + staff_coverage.assigned_count end def members_count @@ -149,29 +153,19 @@ module Teams def assert_can_invite!(staff_kind: "transmission") return unless active? - kind = staff_kind.to_s - unless TeamInvitation::STAFF_KINDS.include?(kind) - raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind") - end - - limit = staff_limit_for(kind) + limit = max_staff_transmission return if limit.nil? - used = staff_count_for(kind) + used = staff_assigned_for return if used < limit - label = kind == "regia" ? "regia" : "trasmissione" raise EntitlementError.new( - "Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.", + "Limite responsabili trasmissione raggiunto (#{limit} all'anno). Passa a un piano superiore.", code: "staff_limit_reached", billing_url: billing_url ) end - def staff_limit_for(kind) - kind == "regia" ? max_staff_regia : max_staff_transmission - end - def assert_can_connect_youtube! unless premium_full? raise EntitlementError.new( @@ -216,10 +210,8 @@ module Teams staff_manage_url: staff_manage_url, max_staff: max_staff, max_staff_transmission: max_staff_transmission, - max_staff_regia: max_staff_regia, staff_used: staff_count, staff_transmission_used: staff_count_for("transmission"), - staff_regia_used: staff_count_for("regia"), concurrent_streams_used: concurrent_streams_used, concurrent_streams_limit: concurrent_streams_limit, recordings_enabled: can_access_recordings?, @@ -230,6 +222,10 @@ module Teams } end + def staff_coverage + @staff_coverage ||= StaffCoverage.new(@team) + end + private def ensure_free_subscription! diff --git a/backend/app/services/teams/staff_assignment.rb b/backend/app/services/teams/staff_assignment.rb index 30538e7..940190a 100644 --- a/backend/app/services/teams/staff_assignment.rb +++ b/backend/app/services/teams/staff_assignment.rb @@ -9,77 +9,55 @@ module Teams end class StaffAssignment - def self.call(team:, user:, staff_kind:, membership: nil) + def self.call(team:, user:, staff_kind: "transmission", membership: nil) new(team: team, user: user, staff_kind: staff_kind, membership: membership).call end - def initialize(team:, user:, staff_kind:, membership: nil) + def initialize(team:, user:, staff_kind: "transmission", membership: nil) @team = team @user = user - @staff_kind = staff_kind.to_s + @staff_kind = "transmission" @membership = membership end def call - unless TeamInvitation::STAFF_KINDS.include?(@staff_kind) - raise StaffAssignmentError, "Ruolo staff non valido" - end - - StaffEmailValidator.assert_available!(team: @team, email: @user.email, staff_kind: @staff_kind, except_user: @user) + StaffEmailValidator.assert_available!( + team: @team, email: @user.email, except_user: @user + ) ut = @membership || @user.user_teams.find_by!(team: @team) - return ut if ut.staff_kind == @staff_kind + return ut if ut.staff_kind == "transmission" - ent = Entitlements.new(@team) - ent.assert_can_invite!(staff_kind: @staff_kind) - ut.update!(staff_kind: @staff_kind) + Entitlements.new(@team).assert_can_invite! + ut.update!(staff_kind: "transmission") ut end - end class StaffEmailValidator - def self.assert_available!(team:, email:, staff_kind:, except_user: nil) - new(team: team, email: email, staff_kind: staff_kind, except_user: except_user).assert_available! + def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil) + new(team: team, email: email, except_user: except_user).assert_available! end - def initialize(team:, email:, staff_kind:, except_user: nil) + def initialize(team:, email:, except_user: nil) @team = team @email = email.to_s.downcase.strip - @staff_kind = staff_kind.to_s @except_user = except_user end def assert_available! - if @except_user - ut = @team.user_teams.find_by(user: @except_user) - if ut&.staff_kind.present? && ut.staff_kind != @staff_kind - other_label = ut.staff_kind == "regia" ? "regia" : "trasmissione" - raise StaffAssignmentError, - "Questo account è già staff #{other_label}. Trasmissione e regia devono essere email diverse." - end + user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil) + user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user + if user_scope.exists? + raise StaffAssignmentError, + "L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra." end - conflict = conflicting_assignment - return unless conflict + inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email) + return unless inv.exists? - other_label = conflict[:kind] == "regia" ? "regia" : "trasmissione" raise StaffAssignmentError, - "L'email #{@email} è già assegnata come staff #{other_label}. Trasmissione e regia devono essere persone (email) diverse." - end - - def conflicting_assignment - other_kind = @staff_kind == "transmission" ? "regia" : "transmission" - - user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where(staff_kind: other_kind) - user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user - - return { kind: other_kind, source: :member } if user_scope.exists? - - inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email).where(staff_kind: other_kind) - return { kind: other_kind, source: :invitation } if inv.exists? - - nil + "Esiste già un invito in sospeso per #{@email}." end end end diff --git a/backend/app/services/teams/staff_coverage.rb b/backend/app/services/teams/staff_coverage.rb new file mode 100644 index 0000000..8302475 --- /dev/null +++ b/backend/app/services/teams/staff_coverage.rb @@ -0,0 +1,35 @@ +module Teams + class StaffCoverage + def initialize(team) + @team = team + end + + def assigned_count(_kind = "transmission") + members_count + pending_invitations_count + end + + def covered_count(_kind = "transmission") + assigned_count.positive? ? 1 : 0 + end + + def role_label_for(membership) + return "Trasmissione" if membership.staff_kind.present? + + nil + end + + def open_slot_for_invite?(_kind = "transmission") + assigned_count < 1 + end + + private + + def members_count + @team.user_teams.where.not(staff_kind: nil).count + end + + def pending_invitations_count + @team.team_invitations.pending.count + end + end +end diff --git a/backend/app/views/admin/billing_invoices/index.html.erb b/backend/app/views/admin/billing_invoices/index.html.erb new file mode 100644 index 0000000..ab67758 --- /dev/null +++ b/backend/app/views/admin/billing_invoices/index.html.erb @@ -0,0 +1,23 @@ +
<%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %>
+ +<% if @invoices.any? %> +| Numero | Data | Importo | Stato | |
|---|---|---|---|---|
| <%= inv.number %> | +<%= inv.issued_on %> | +<%= inv.formatted_amount %> | +<%= inv.status %> | +<%= inv.pdf.attached? ? "Sì" : "No" %> | +
Nessuna fattura.
+<% end %> diff --git a/backend/app/views/admin/billing_invoices/new.html.erb b/backend/app/views/admin/billing_invoices/new.html.erb new file mode 100644 index 0000000..e30a097 --- /dev/null +++ b/backend/app/views/admin/billing_invoices/new.html.erb @@ -0,0 +1,36 @@ +Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.
+ +<%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %>
diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb index e5fb045..f8ab7b1 100644 --- a/backend/app/views/admin/dashboard/index.html.erb +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -99,7 +99,10 @@Status: <%= @session.status %>
+<% unless @session.terminal? %> ++ <%= button_to "Termina sessione", + stop_admin_session_path(@session), + method: :post, + class: "admin-btn admin-btn--danger", + form: { data: { turbo_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended." } } %> +
+<% end %>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %>
<% if @session.youtube_broadcast_id %>YouTube: Broadcast
diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb index 13b4a88..102c1c0 100644 --- a/backend/app/views/admin/teams/show.html.erb +++ b/backend/app/views/admin/teams/show.html.erb @@ -1,5 +1,8 @@Sport: <%= @team.sport %>
+<% if @team.club %> +Società: <%= @team.club.name %> · <%= link_to "Fatture società", admin_club_billing_invoices_path(@team.club) %>
+<% end %>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>