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:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
module Admin
class BaseController < ActionController::Base
layout "admin"
protect_from_forgery with: :null_session
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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