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