Initial commit: monorepo Match Live TV.
Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
backend/app/controllers/admin/base_controller.rb
Normal file
6
backend/app/controllers/admin/base_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module Admin
|
||||
class BaseController < ActionController::Base
|
||||
layout "admin"
|
||||
protect_from_forgery with: :null_session
|
||||
end
|
||||
end
|
||||
8
backend/app/controllers/admin/dashboard_controller.rb
Normal file
8
backend/app/controllers/admin/dashboard_controller.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
module Admin
|
||||
class DashboardController < Admin::BaseController
|
||||
def index
|
||||
@teams = Team.includes(:matches).order(:name)
|
||||
@active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match)
|
||||
end
|
||||
end
|
||||
end
|
||||
12
backend/app/controllers/admin/sessions_controller.rb
Normal file
12
backend/app/controllers/admin/sessions_controller.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Admin
|
||||
class SessionsController < Admin::BaseController
|
||||
def index
|
||||
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
|
||||
end
|
||||
|
||||
def show
|
||||
@session = StreamSession.find(params[:id])
|
||||
@events = @session.stream_events.recent.limit(50)
|
||||
end
|
||||
end
|
||||
end
|
||||
12
backend/app/controllers/admin/teams_controller.rb
Normal file
12
backend/app/controllers/admin/teams_controller.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Admin
|
||||
class TeamsController < Admin::BaseController
|
||||
def index
|
||||
@teams = Team.all.order(:name)
|
||||
end
|
||||
|
||||
def show
|
||||
@team = Team.find(params[:id])
|
||||
@matches = @team.matches.order(scheduled_at: :desc)
|
||||
end
|
||||
end
|
||||
end
|
||||
65
backend/app/controllers/api/v1/auth_controller.rb
Normal file
65
backend/app/controllers/api/v1/auth_controller.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
module Api
|
||||
module V1
|
||||
class AuthController < ApplicationController
|
||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
||||
|
||||
def register
|
||||
user = User.new(
|
||||
email: params[:email]&.downcase,
|
||||
name: params[:name],
|
||||
password: params[:password],
|
||||
role: "coach"
|
||||
)
|
||||
if user.save
|
||||
render json: token_response(user), status: :created
|
||||
else
|
||||
render json: { error: user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def login
|
||||
user = User.find_by(email: params[:email]&.downcase)
|
||||
if user&.authenticate(params[:password])
|
||||
render json: token_response(user), status: :ok
|
||||
else
|
||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def logout
|
||||
render json: { message: "Logged out" }
|
||||
end
|
||||
|
||||
def refresh
|
||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||
user = User.find_by(id: payload&.dig(:user_id))
|
||||
return render json: { error: "Invalid token" }, status: :unauthorized unless user
|
||||
|
||||
render json: token_response(user)
|
||||
end
|
||||
|
||||
def me
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_response(user)
|
||||
{
|
||||
user: user_json(user),
|
||||
access_token: JsonWebToken.encode({ user_id: user.id, type: "access" }),
|
||||
refresh_token: JsonWebToken.encode({ user_id: user.id, type: "refresh" }, exp: 30.days.from_now)
|
||||
}
|
||||
end
|
||||
|
||||
def user_json(user)
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
backend/app/controllers/api/v1/base_controller.rb
Normal file
17
backend/app/controllers/api/v1/base_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module Api
|
||||
module V1
|
||||
class BaseController < ApplicationController
|
||||
rescue_from Teams::EntitlementError, with: :render_entitlement_error
|
||||
|
||||
private
|
||||
|
||||
def render_entitlement_error(error)
|
||||
render json: {
|
||||
error: error.message,
|
||||
error_code: error.code,
|
||||
billing_url: error.billing_url
|
||||
}, status: :forbidden
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
85
backend/app/controllers/api/v1/matches_controller.rb
Normal file
85
backend/app/controllers/api/v1/matches_controller.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
module Api
|
||||
module V1
|
||||
class MatchesController < BaseController
|
||||
before_action :set_team, only: %i[index create]
|
||||
before_action :set_match, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
matches = @team.matches
|
||||
.includes(:team, :stream_sessions)
|
||||
.order(scheduled_at: :desc)
|
||||
render json: matches.map { |m| match_json(m) }
|
||||
end
|
||||
|
||||
def create
|
||||
match = @team.matches.create!(match_params)
|
||||
render json: match_json(match), status: :created
|
||||
end
|
||||
|
||||
def show
|
||||
render json: match_json(@match, detail: true)
|
||||
end
|
||||
|
||||
def update
|
||||
@match.update!(match_params)
|
||||
render json: match_json(@match)
|
||||
end
|
||||
|
||||
def destroy
|
||||
active = active_session_for(@match)
|
||||
if active&.resumable?
|
||||
return render json: {
|
||||
error: "Chiudi la diretta prima di eliminare questa partita"
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@match.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_user.teams.find(params[:team_id])
|
||||
end
|
||||
|
||||
def set_match
|
||||
@match = Match.joins(:team).merge(current_user.teams).find(params[:id])
|
||||
end
|
||||
|
||||
def match_params
|
||||
params.require(:match).permit(
|
||||
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
|
||||
:category, :phase, roster_numbers: [],
|
||||
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
|
||||
)
|
||||
end
|
||||
|
||||
def match_json(match, detail: false)
|
||||
active = active_session_for(match)
|
||||
{
|
||||
id: match.id,
|
||||
team_id: match.team_id,
|
||||
team_name: match.team.name,
|
||||
opponent_name: match.opponent_name,
|
||||
location: match.location,
|
||||
scheduled_at: match.scheduled_at,
|
||||
sport: match.sport,
|
||||
sets_to_win: match.sets_to_win,
|
||||
scoring_rules: match.scoring_rules.presence,
|
||||
roster_numbers: match.roster_numbers,
|
||||
category: match.category,
|
||||
phase: match.phase,
|
||||
active_session_id: active&.id,
|
||||
active_session_status: active&.status
|
||||
}
|
||||
end
|
||||
|
||||
def active_session_for(match)
|
||||
match.stream_sessions
|
||||
.order(created_at: :desc)
|
||||
.find { |s| !%w[ended error].include?(s.status) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
146
backend/app/controllers/api/v1/stream_sessions_controller.rb
Normal file
146
backend/app/controllers/api/v1/stream_sessions_controller.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
module Api
|
||||
module V1
|
||||
class StreamSessionsController < BaseController
|
||||
before_action :set_session, except: :create
|
||||
|
||||
def create
|
||||
match = Match.joins(:team).merge(current_user.teams).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
|
||||
|
||||
def show
|
||||
render json: session_json(@session, detail: true)
|
||||
end
|
||||
|
||||
def start
|
||||
Sessions::Start.new(@session).call
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def stop
|
||||
Sessions::Stop.new(@session).call
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def pause
|
||||
Sessions::Pause.new(@session).call
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def events
|
||||
events = @session.stream_events.recent.limit(100)
|
||||
render json: events.map { |e| event_json(e) }
|
||||
end
|
||||
|
||||
def telemetry
|
||||
role = params.require(:device_role)
|
||||
state = @session.device_states.find_or_initialize_by(device_role: role)
|
||||
state.update!(
|
||||
battery_level: params[:battery_level],
|
||||
network_type: params[:network_type],
|
||||
signal_strength: params[:signal_strength],
|
||||
current_bitrate: params[:current_bitrate],
|
||||
target_bitrate: params[:target_bitrate],
|
||||
fps: params[:fps],
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def pairing_token
|
||||
token = SecureRandom.urlsafe_base64(24)
|
||||
@session.update!(
|
||||
pairing_token_digest: Digest::SHA256.hexdigest(token),
|
||||
pairing_token_expires_at: 15.minutes.from_now
|
||||
)
|
||||
render json: {
|
||||
pairing_token: token,
|
||||
expires_at: @session.pairing_token_expires_at,
|
||||
qr_payload: {
|
||||
session_id: @session.id,
|
||||
pairing_token: token,
|
||||
api_url: request.base_url
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def claim_pairing
|
||||
token = params.require(:pairing_token)
|
||||
digest = Digest::SHA256.hexdigest(token)
|
||||
unless @session.pairing_token_digest == digest &&
|
||||
@session.pairing_token_expires_at&.future?
|
||||
return render json: { error: "Invalid or expired token" }, status: :unauthorized
|
||||
end
|
||||
|
||||
@session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def network_test
|
||||
@session.stream_events.create!(
|
||||
event_type: "network_test",
|
||||
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
|
||||
occurred_at: Time.current
|
||||
)
|
||||
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
|
||||
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
|
||||
end
|
||||
|
||||
def youtube_stats
|
||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||
Youtube::BroadcastService.new(@session.match.team)
|
||||
.viewer_count(@session.youtube_broadcast_id)
|
||||
else
|
||||
0
|
||||
end
|
||||
render json: { concurrent_viewers: count, live: @session.live? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = StreamSession.joins(match: :team)
|
||||
.merge(current_user.teams)
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def session_params
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps)
|
||||
end
|
||||
|
||||
def session_json(session, detail: false)
|
||||
data = {
|
||||
id: session.id,
|
||||
match_id: session.match_id,
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
rtmp_ingest_url: session.rtmp_ingest_url,
|
||||
hls_playback_url: session.hls_playback_url,
|
||||
watch_page_url: session.watch_page_url,
|
||||
youtube_broadcast_id: session.youtube_broadcast_id,
|
||||
privacy_status: session.privacy_status,
|
||||
quality_preset: session.quality_preset,
|
||||
target_bitrate: session.target_bitrate,
|
||||
started_at: session.started_at,
|
||||
disconnection_count: session.disconnection_count
|
||||
}
|
||||
if detail
|
||||
data[:score] = session.score_state&.as_cable_payload
|
||||
data[:devices] = session.device_states.map(&:as_cable_payload)
|
||||
end
|
||||
data
|
||||
end
|
||||
|
||||
def event_json(event)
|
||||
{
|
||||
id: event.id,
|
||||
event_type: event.event_type,
|
||||
metadata: event.metadata,
|
||||
occurred_at: event.occurred_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
122
backend/app/controllers/api/v1/teams_controller.rb
Normal file
122
backend/app/controllers/api/v1/teams_controller.rb
Normal file
@@ -0,0 +1,122 @@
|
||||
module Api
|
||||
module V1
|
||||
class TeamsController < BaseController
|
||||
def index
|
||||
teams = current_user.teams.includes(:matches)
|
||||
render json: teams.map { |t| team_json(t) }
|
||||
end
|
||||
|
||||
def show
|
||||
team = current_user.teams.find(params[:id])
|
||||
render json: team_json(team, detail: true)
|
||||
end
|
||||
|
||||
def create
|
||||
if current_user.user_teams.where(role: "owner").count >= 1
|
||||
return render json: {
|
||||
error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci."
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
team = Team.create!(team_params)
|
||||
UserTeam.create!(user: current_user, team: team, role: "owner")
|
||||
Billing::AssignPlan.call(team: team, plan_slug: "free")
|
||||
render json: team_json(team), status: :created
|
||||
end
|
||||
|
||||
def recordings
|
||||
team = current_user.teams.find(params[:id])
|
||||
unless team.entitlements.can_access_recordings?
|
||||
return render json: {
|
||||
error: "Archivio gare disponibile con Premium Light o Full",
|
||||
error_code: "premium_required",
|
||||
billing_url: team.entitlements.billing_url
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50)
|
||||
render json: recs.map { |r| recording_json(r) }
|
||||
end
|
||||
|
||||
def update
|
||||
team = current_user.teams.find(params[:id])
|
||||
team.update!(team_params)
|
||||
render json: team_json(team)
|
||||
end
|
||||
|
||||
def add_member
|
||||
team = current_user.teams.find(params[:id])
|
||||
team.entitlements.assert_can_invite!
|
||||
member = User.find(params[:user_id])
|
||||
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def remove_member
|
||||
team = current_user.teams.find(params[:id])
|
||||
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
||||
ut.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def team_params
|
||||
params.require(:team).permit(:name, :sport, :logo_url)
|
||||
end
|
||||
|
||||
def team_json(team, detail: false)
|
||||
ent = team.entitlements
|
||||
data = {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
sport: team.sport,
|
||||
logo_url: team.logo_url,
|
||||
youtube_connected: team.youtube_credential.present?,
|
||||
plan_slug: ent.plan.slug,
|
||||
plan_name: ent.plan.name,
|
||||
premium_active: ent.premium_active?,
|
||||
premium_full: ent.premium_full?,
|
||||
subscription_status: ent.subscription.status,
|
||||
features: ent.plan.features,
|
||||
billing_url: ent.billing_url,
|
||||
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
|
||||
}
|
||||
if detail
|
||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||
{ id: ut.user.id, name: ut.user.name, email: ut.user.email, role: ut.role }
|
||||
end
|
||||
end
|
||||
data
|
||||
end
|
||||
|
||||
def recording_json(recording)
|
||||
session = recording.stream_session
|
||||
match = session.match
|
||||
{
|
||||
id: recording.id,
|
||||
session_id: session.id,
|
||||
match_id: match.id,
|
||||
opponent_name: match.opponent_name,
|
||||
team_name: match.team.name,
|
||||
ended_at: session.ended_at,
|
||||
replay_url: recording.replay_url,
|
||||
download_url: recording.replay_url,
|
||||
expires_at: recording.expires_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
backend/app/controllers/api/v1/youtube_controller.rb
Normal file
26
backend/app/controllers/api/v1/youtube_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
module Api
|
||||
module V1
|
||||
class YoutubeController < BaseController
|
||||
def authorize
|
||||
team = current_user.teams.find(params[:team_id])
|
||||
team.entitlements.assert_can_connect_youtube!
|
||||
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
|
||||
render json: { authorization_url: url }
|
||||
end
|
||||
|
||||
def callback
|
||||
team = current_user.teams.find(params[:state])
|
||||
tokens = Youtube::OauthExchange.call(params[:code])
|
||||
cred = team.youtube_credential || team.build_youtube_credential
|
||||
cred.update!(
|
||||
access_token: tokens[:access_token],
|
||||
refresh_token: tokens[:refresh_token],
|
||||
expires_at: Time.current + tokens[:expires_in].seconds,
|
||||
channel_id: tokens[:channel_id],
|
||||
channel_title: tokens[:channel_title]
|
||||
)
|
||||
redirect_to "/admin/teams/#{team.id}?youtube=connected"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
25
backend/app/controllers/application_controller.rb
Normal file
25
backend/app/controllers/application_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||
|
||||
before_action :authenticate_request!
|
||||
|
||||
attr_reader :current_user
|
||||
|
||||
private
|
||||
|
||||
def authenticate_request!
|
||||
token = bearer_token
|
||||
payload = JsonWebToken.decode(token)
|
||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
auth = request.headers["Authorization"].to_s
|
||||
auth.start_with?("Bearer ") ? auth.split(" ", 2).last : nil
|
||||
end
|
||||
|
||||
def skip_auth?
|
||||
false
|
||||
end
|
||||
end
|
||||
0
backend/app/controllers/concerns/.keep
Normal file
0
backend/app/controllers/concerns/.keep
Normal file
44
backend/app/controllers/hls_proxy_controller.rb
Normal file
44
backend/app/controllers/hls_proxy_controller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class HlsProxyController < ActionController::Base
|
||||
# Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000).
|
||||
def show
|
||||
upstream_path = params[:path].to_s
|
||||
return head :not_found if upstream_path.blank?
|
||||
|
||||
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
|
||||
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
|
||||
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
||||
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
|
||||
|
||||
if response.status == 302
|
||||
location = response.headers["location"].to_s
|
||||
follow_url = if location.start_with?("http")
|
||||
location
|
||||
else
|
||||
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}"
|
||||
end
|
||||
set_cookie = response.headers["set-cookie"].to_s
|
||||
cookie = set_cookie.presence || cookie
|
||||
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
|
||||
end
|
||||
|
||||
response.headers.each do |key, value|
|
||||
next if %w[transfer-encoding connection].include?(key.downcase)
|
||||
|
||||
headers[key] = value
|
||||
end
|
||||
render body: response.body, status: response.status
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
|
||||
head :bad_gateway
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hls_conn
|
||||
@hls_conn ||= Faraday.new do |f|
|
||||
f.adapter Faraday.default_adapter
|
||||
f.options.timeout = 15
|
||||
f.options.open_timeout = 5
|
||||
end
|
||||
end
|
||||
end
|
||||
28
backend/app/controllers/public/invitations_controller.rb
Normal file
28
backend/app/controllers/public/invitations_controller.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Public
|
||||
class InvitationsController < WebBaseController
|
||||
def show
|
||||
@token = params[:token]
|
||||
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
|
||||
unless @invitation
|
||||
redirect_to public_pricing_path, alert: "Invito non valido o scaduto"
|
||||
end
|
||||
end
|
||||
|
||||
def accept
|
||||
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
|
||||
return redirect_to public_pricing_path, alert: "Invito non valido" unless invitation
|
||||
|
||||
if logged_in?
|
||||
if current_user.email.downcase != invitation.email.downcase
|
||||
redirect_to public_pricing_path, alert: "Questo invito è per #{invitation.email}"
|
||||
return
|
||||
end
|
||||
invitation.accept!(current_user)
|
||||
redirect_to public_team_dashboard_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"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
47
backend/app/controllers/public/live_controller.rb
Normal file
47
backend/app/controllers/public/live_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
module Public
|
||||
class LiveController < SiteBaseController
|
||||
layout "marketing_live"
|
||||
|
||||
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.strip
|
||||
@sessions = StreamSession
|
||||
.broadcasting
|
||||
.includes(:score_state, match: :team)
|
||||
.search_by_team_or_opponent(@query)
|
||||
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
|
||||
|
||||
@online_paths = Mediamtx::Client.new.online_path_names
|
||||
end
|
||||
|
||||
def show
|
||||
@session = StreamSession.includes(match: :team).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])
|
||||
closed = session.terminal?
|
||||
render json: {
|
||||
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,
|
||||
score: session.score_state&.as_cable_payload
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_online?(session)
|
||||
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
|
||||
@online_paths_cache.include?(session.mediamtx_path_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
19
backend/app/controllers/public/pages_controller.rb
Normal file
19
backend/app/controllers/public/pages_controller.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Public
|
||||
class PagesController < WebBaseController
|
||||
def home
|
||||
end
|
||||
|
||||
def features
|
||||
end
|
||||
|
||||
def pricing
|
||||
@plans = Plan.ordered
|
||||
end
|
||||
|
||||
def privacy
|
||||
end
|
||||
|
||||
def terms
|
||||
end
|
||||
end
|
||||
end
|
||||
33
backend/app/controllers/public/registrations_controller.rb
Normal file
33
backend/app/controllers/public/registrations_controller.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module Public
|
||||
class RegistrationsController < WebBaseController
|
||||
def new
|
||||
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
|
||||
@user = User.new
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params.merge(role: "coach"))
|
||||
if @user.save
|
||||
session[:user_id] = @user.id
|
||||
if session[:pending_invite_token].present?
|
||||
token = session.delete(:pending_invite_token)
|
||||
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!"
|
||||
end
|
||||
end
|
||||
redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra."
|
||||
else
|
||||
flash.now[:alert] = @user.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
end
|
||||
15
backend/app/controllers/public/replay_controller.rb
Normal file
15
backend/app/controllers/public/replay_controller.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Public
|
||||
class ReplayController < SiteBaseController
|
||||
layout "marketing_live"
|
||||
|
||||
def show
|
||||
@session = StreamSession.includes(match: :team).find(params[:id])
|
||||
@recording = Recording.ready.find_by(stream_session: @session)
|
||||
@match = @session.match
|
||||
|
||||
unless @recording
|
||||
redirect_to public_live_path(@session), alert: "Replay non disponibile"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
backend/app/controllers/public/sessions_controller.rb
Normal file
24
backend/app/controllers/public/sessions_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
module Public
|
||||
class SessionsController < WebBaseController
|
||||
def new
|
||||
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email]&.downcase)
|
||||
if user&.authenticate(params[:password])
|
||||
session[:user_id] = user.id
|
||||
dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path
|
||||
redirect_to dest, notice: "Bentornato!"
|
||||
else
|
||||
flash.now[:alert] = "Email o password non validi"
|
||||
render :new, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
reset_session
|
||||
redirect_to public_pricing_path, notice: "Disconnesso"
|
||||
end
|
||||
end
|
||||
end
|
||||
19
backend/app/controllers/public/site_base_controller.rb
Normal file
19
backend/app/controllers/public/site_base_controller.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Public
|
||||
class SiteBaseController < ActionController::Base
|
||||
layout "marketing"
|
||||
|
||||
helper_method :current_user, :logged_in?
|
||||
|
||||
private
|
||||
|
||||
def current_user
|
||||
return @current_user if defined?(@current_user)
|
||||
|
||||
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
current_user.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
119
backend/app/controllers/public/teams_controller.rb
Normal file
119
backend/app/controllers/public/teams_controller.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
module Public
|
||||
class TeamsController < WebBaseController
|
||||
before_action :require_login!
|
||||
before_action :set_team, only: %i[
|
||||
dashboard billing checkout portal invite create_invitation
|
||||
remove_member destroy_invitation
|
||||
]
|
||||
|
||||
def new
|
||||
redirect_to public_team_dashboard_path(current_user.teams.first) if current_user.teams.any?
|
||||
end
|
||||
|
||||
def create
|
||||
if current_user.user_teams.where(role: "owner").exists?
|
||||
redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra"
|
||||
return
|
||||
end
|
||||
|
||||
team = Team.create!(team_params)
|
||||
UserTeam.create!(user: current_user, team: team, role: "owner")
|
||||
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
|
||||
Billing::AssignPlan.call(team: team, plan_slug: plan)
|
||||
|
||||
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
|
||||
redirect_to public_team_checkout_path(team, plan: plan)
|
||||
else
|
||||
redirect_to public_team_dashboard_path(team), notice: "Squadra creata con piano Free"
|
||||
end
|
||||
end
|
||||
|
||||
def dashboard
|
||||
require_team_owner!(@team)
|
||||
@entitlements = @team.entitlements
|
||||
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
||||
@members = @team.user_teams.includes(:user).where.not(role: "owner")
|
||||
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def billing
|
||||
require_team_owner!(@team)
|
||||
@entitlements = @team.entitlements
|
||||
@plans = Plan.ordered
|
||||
end
|
||||
|
||||
def checkout
|
||||
require_team_owner!(@team)
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server"
|
||||
return
|
||||
end
|
||||
|
||||
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
|
||||
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url
|
||||
redirect_to url, allow_other_host: true
|
||||
end
|
||||
|
||||
def portal
|
||||
require_team_owner!(@team)
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
redirect_to public_team_billing_path(@team), alert: "Portale pagamenti non configurato"
|
||||
return
|
||||
end
|
||||
|
||||
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user).portal_url
|
||||
redirect_to url, allow_other_host: true
|
||||
end
|
||||
|
||||
def invite
|
||||
require_team_owner!(@team)
|
||||
@entitlements = @team.entitlements
|
||||
end
|
||||
|
||||
def create_invitation
|
||||
require_team_owner!(@team)
|
||||
@entitlements = @team.entitlements
|
||||
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
|
||||
@entitlements.assert_can_invite!(staff_kind: staff_kind)
|
||||
|
||||
email = params[:email]&.downcase&.strip
|
||||
token = TeamInvitation.generate_token
|
||||
@team.team_invitations.create!(
|
||||
email: email,
|
||||
token_digest: Digest::SHA256.hexdigest(token),
|
||||
role: "member",
|
||||
staff_kind: staff_kind,
|
||||
expires_at: 7.days.from_now
|
||||
)
|
||||
@invite_url = join_public_invitation_url(token: token)
|
||||
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
|
||||
render :invite
|
||||
rescue Teams::EntitlementError => e
|
||||
redirect_to public_team_invite_path(@team), alert: e.message
|
||||
end
|
||||
|
||||
def remove_member
|
||||
require_team_owner!(@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"
|
||||
end
|
||||
|
||||
def destroy_invitation
|
||||
require_team_owner!(@team)
|
||||
inv = @team.team_invitations.pending.find(params[:invitation_id])
|
||||
inv.destroy!
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_user.teams.find(params[:id])
|
||||
end
|
||||
|
||||
def team_params
|
||||
params.require(:team).permit(:name, :sport, :logo_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
18
backend/app/controllers/public/web_base_controller.rb
Normal file
18
backend/app/controllers/public/web_base_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module Public
|
||||
class WebBaseController < SiteBaseController
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
private
|
||||
|
||||
def require_login!
|
||||
return if logged_in?
|
||||
|
||||
redirect_to public_login_path, alert: "Accedi per continuare"
|
||||
end
|
||||
|
||||
def require_team_owner!(team)
|
||||
membership = current_user.user_teams.find_by(team: team)
|
||||
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner"
|
||||
end
|
||||
end
|
||||
end
|
||||
53
backend/app/controllers/webhooks/mediamtx_controller.rb
Normal file
53
backend/app/controllers/webhooks/mediamtx_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
module Webhooks
|
||||
class MediamtxController < ApplicationController
|
||||
skip_before_action :authenticate_request!
|
||||
|
||||
before_action :verify_signature!
|
||||
|
||||
def connect
|
||||
handle_event("connect")
|
||||
end
|
||||
|
||||
def disconnect
|
||||
handle_event("disconnect")
|
||||
end
|
||||
|
||||
def ready
|
||||
handle_event("ready")
|
||||
end
|
||||
|
||||
def validate_publish
|
||||
session_id = params[:session_id] || extract_session_id(params[:path])
|
||||
token = params[:token]
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
|
||||
valid = session && session.publish_token == token && !session.ended?
|
||||
render json: { valid: valid }, status: valid ? :ok : :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_event(event)
|
||||
session_id = params[:session_id].presence || extract_session_id(params[:path])
|
||||
Webhooks::MediamtxHandler.new(
|
||||
event: event,
|
||||
session_id: session_id,
|
||||
metadata: params.permit!.to_h.except("controller", "action")
|
||||
).call
|
||||
head :ok
|
||||
end
|
||||
|
||||
def extract_session_id(path)
|
||||
path.to_s[%r{match_([0-9a-f-]+)}, 1]
|
||||
end
|
||||
|
||||
def verify_signature!
|
||||
signature = request.headers["X-MediaMTX-Signature"]
|
||||
payload = request.raw_post.presence || request.query_string
|
||||
return if Webhooks::Signature.valid?(payload, signature)
|
||||
return if Rails.env.development? && signature.blank?
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
32
backend/app/controllers/webhooks/stripe_controller.rb
Normal file
32
backend/app/controllers/webhooks/stripe_controller.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module Webhooks
|
||||
class StripeController < ActionController::API
|
||||
def create
|
||||
payload = request.body.read
|
||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
||||
|
||||
unless MatchLiveTv.stripe_webhook_secret.present?
|
||||
Rails.logger.info("[Stripe webhook] ignored — STRIPE_WEBHOOK_SECRET not set")
|
||||
return head :ok
|
||||
end
|
||||
|
||||
event = ::Stripe::Webhook.construct_event(
|
||||
payload, sig_header, MatchLiveTv.stripe_webhook_secret
|
||||
)
|
||||
|
||||
Billing::Stripe::WebhookHandler.call(event)
|
||||
|
||||
head :ok
|
||||
rescue JSON::ParserError, ::Stripe::SignatureVerificationError => e
|
||||
Rails.logger.warn("[Stripe webhook] #{e.message}")
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_object_from_hash(hash)
|
||||
OpenStruct.new(hash.transform_keys(&:to_s).transform_values do |v|
|
||||
v.is_a?(Hash) ? stripe_object_from_hash(v) : v
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user