Club come entità principale, branding e fix infrastruttura streaming.
Introduce clubs con abbonamento, branding Active Storage e flusso registrazione società; corregge healthcheck MediaMTX (immagine distroless) e allinea dominio produzione matchlivetv.it. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,7 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
include CableBroadcastable
|
include CableBroadcastable
|
||||||
def subscribed
|
def subscribed
|
||||||
session = StreamSession.joins(match: :team)
|
session = StreamSession.joins(match: :team)
|
||||||
.merge(current_user.teams)
|
.merge(current_user.manageable_teams)
|
||||||
.find(params[:session_id])
|
.find(params[:session_id])
|
||||||
stream_for session
|
stream_for session
|
||||||
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
|
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ module Api
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_team
|
def set_team
|
||||||
@team = current_user.teams.find(params[:team_id])
|
@team = current_user.manageable_teams.find(params[:team_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_match
|
def set_match
|
||||||
@match = Match.joins(:team).merge(current_user.teams).find(params[:id])
|
@match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def match_params
|
def match_params
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Api
|
|||||||
before_action :set_session, except: :create
|
before_action :set_session, except: :create
|
||||||
|
|
||||||
def create
|
def create
|
||||||
match = Match.joins(:team).merge(current_user.teams).find(params[:match_id])
|
match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:match_id])
|
||||||
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
|
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
|
||||||
render json: session_json(session), status: :created
|
render json: session_json(session), status: :created
|
||||||
end
|
end
|
||||||
@@ -102,7 +102,7 @@ module Api
|
|||||||
|
|
||||||
def set_session
|
def set_session
|
||||||
@session = StreamSession.joins(match: :team)
|
@session = StreamSession.joins(match: :team)
|
||||||
.merge(current_user.teams)
|
.merge(current_user.manageable_teams)
|
||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,32 @@ module Api
|
|||||||
module V1
|
module V1
|
||||||
class TeamsController < BaseController
|
class TeamsController < BaseController
|
||||||
def index
|
def index
|
||||||
teams = current_user.teams.includes(:matches)
|
teams = current_user.manageable_teams.includes(:club, :matches)
|
||||||
render json: teams.map { |t| team_json(t) }
|
render json: teams.map { |t| team_json(t) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
team = current_user.teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
render json: team_json(team, detail: true)
|
render json: team_json(team, detail: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if current_user.user_teams.where(role: "owner").count >= 1
|
if current_user.owned_clubs.exists?
|
||||||
return render json: {
|
return render json: {
|
||||||
error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci."
|
error: "Registra o gestisci la società dal sito web per aggiungere squadre."
|
||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
team = Team.create!(team_params)
|
club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball",
|
||||||
UserTeam.create!(user: current_user, team: team, role: "owner")
|
primary_color: "#e53935", secondary_color: "#ffffff")
|
||||||
Billing::AssignPlan.call(team: team, plan_slug: "free")
|
ClubMembership.create!(user: current_user, club: club, role: "owner")
|
||||||
|
team = club.teams.create!(name: team_params[:name], sport: club.sport)
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||||
render json: team_json(team), status: :created
|
render json: team_json(team), status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def recordings
|
def recordings
|
||||||
team = current_user.teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
unless team.entitlements.can_access_recordings?
|
unless team.entitlements.can_access_recordings?
|
||||||
return render json: {
|
return render json: {
|
||||||
error: "Archivio gare disponibile con Premium Light o Full",
|
error: "Archivio gare disponibile con Premium Light o Full",
|
||||||
@@ -39,13 +41,13 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
team = current_user.teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
team.update!(team_params)
|
team.update!(team_params)
|
||||||
render json: team_json(team)
|
render json: team_json(team)
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_member
|
def add_member
|
||||||
team = current_user.teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
team.entitlements.assert_can_invite!
|
team.entitlements.assert_can_invite!
|
||||||
member = User.find(params[:user_id])
|
member = User.find(params[:user_id])
|
||||||
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
|
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
|
||||||
@@ -53,7 +55,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def remove_member
|
def remove_member
|
||||||
team = current_user.teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
||||||
ut.destroy!
|
ut.destroy!
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -71,7 +73,11 @@ module Api
|
|||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
sport: team.sport,
|
sport: team.sport,
|
||||||
logo_url: team.logo_url,
|
logo_url: team.effective_logo_url,
|
||||||
|
primary_color: team.effective_primary_color,
|
||||||
|
secondary_color: team.effective_secondary_color,
|
||||||
|
club_id: team.club_id,
|
||||||
|
club_name: team.club.name,
|
||||||
youtube_connected: team.youtube_credential.present?,
|
youtube_connected: team.youtube_credential.present?,
|
||||||
plan_slug: ent.plan.slug,
|
plan_slug: ent.plan.slug,
|
||||||
plan_name: ent.plan.name,
|
plan_name: ent.plan.name,
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ module Api
|
|||||||
module V1
|
module V1
|
||||||
class YoutubeController < BaseController
|
class YoutubeController < BaseController
|
||||||
def authorize
|
def authorize
|
||||||
team = current_user.teams.find(params[:team_id])
|
team = current_user.manageable_teams.find(params[:team_id])
|
||||||
team.entitlements.assert_can_connect_youtube!
|
team.entitlements.assert_can_connect_youtube!
|
||||||
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
|
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
|
||||||
render json: { authorization_url: url }
|
render json: { authorization_url: url }
|
||||||
end
|
end
|
||||||
|
|
||||||
def callback
|
def callback
|
||||||
team = current_user.teams.find(params[:state])
|
team = current_user.manageable_teams.find(params[:state])
|
||||||
tokens = Youtube::OauthExchange.call(params[:code])
|
tokens = Youtube::OauthExchange.call(params[:code])
|
||||||
cred = team.youtube_credential || team.build_youtube_credential
|
cred = team.youtube_credential || team.build_youtube_credential
|
||||||
cred.update!(
|
cred.update!(
|
||||||
|
|||||||
16
backend/app/controllers/concerns/branding_attachments.rb
Normal file
16
backend/app/controllers/concerns/branding_attachments.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module BrandingAttachments
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def attach_branding_logo(record)
|
||||||
|
file = params.dig(:branding, :logo_file)
|
||||||
|
record.logo_file.attach(file) if file.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_hex_color(value, fallback)
|
||||||
|
v = value.to_s.strip
|
||||||
|
v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/)
|
||||||
|
v.match?(Brandable::HEX_COLOR) ? v : fallback
|
||||||
|
end
|
||||||
|
end
|
||||||
108
backend/app/controllers/public/clubs_controller.rb
Normal file
108
backend/app/controllers/public/clubs_controller.rb
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
module Public
|
||||||
|
class ClubsController < WebBaseController
|
||||||
|
include BrandingAttachments
|
||||||
|
|
||||||
|
before_action :require_login!
|
||||||
|
before_action :set_club, only: %i[show edit update billing checkout portal]
|
||||||
|
|
||||||
|
def new
|
||||||
|
redirect_to public_club_path(current_user.primary_club) if current_user.primary_club
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
if current_user.owned_clubs.exists?
|
||||||
|
redirect_to public_club_path(current_user.primary_club), alert: "Hai già registrato una società"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
club = Club.new(club_params)
|
||||||
|
attach_branding_logo(club)
|
||||||
|
club.save!
|
||||||
|
|
||||||
|
ClubMembership.create!(user: current_user, club: club, role: "owner")
|
||||||
|
|
||||||
|
first_team_name = params.dig(:first_team, :name).presence || "Prima squadra"
|
||||||
|
club.teams.create!(
|
||||||
|
name: first_team_name,
|
||||||
|
sport: club.sport
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: plan)
|
||||||
|
|
||||||
|
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
|
||||||
|
redirect_to public_club_checkout_path(club, plan: plan)
|
||||||
|
else
|
||||||
|
redirect_to public_club_path(club), notice: "Società e prima squadra create."
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
require_club_owner!(@club)
|
||||||
|
@entitlements_team = @club.teams.first
|
||||||
|
@entitlements = @entitlements_team&.entitlements
|
||||||
|
@teams = @club.teams.order(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
require_club_owner!(@club)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
require_club_owner!(@club)
|
||||||
|
@club.assign_attributes(club_params)
|
||||||
|
attach_branding_logo(@club)
|
||||||
|
@club.save!
|
||||||
|
redirect_to public_club_path(@club), notice: "Società aggiornata."
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def billing
|
||||||
|
require_club_owner!(@club)
|
||||||
|
@team = @club.teams.first!
|
||||||
|
@entitlements = @team.entitlements
|
||||||
|
@plans = Plan.ordered
|
||||||
|
end
|
||||||
|
|
||||||
|
def checkout
|
||||||
|
require_club_owner!(@club)
|
||||||
|
unless MatchLiveTv.stripe_enabled?
|
||||||
|
redirect_to public_club_billing_path(@club), 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(club: @club, user: current_user, plan_slug: plan_slug).url
|
||||||
|
redirect_to url, allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def portal
|
||||||
|
require_club_owner!(@club)
|
||||||
|
unless MatchLiveTv.stripe_enabled?
|
||||||
|
redirect_to public_club_billing_path(@club), alert: "Portale pagamenti non configurato"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user).portal_url
|
||||||
|
redirect_to url, allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_club
|
||||||
|
@club = current_user.owned_clubs.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_params
|
||||||
|
p = params.require(:club).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
|
||||||
|
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
|
||||||
|
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
|
||||||
|
p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module Public
|
module Public
|
||||||
class RegistrationsController < WebBaseController
|
class RegistrationsController < WebBaseController
|
||||||
def new
|
def new
|
||||||
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
|
redirect_to public_club_path(current_user.primary_club) if logged_in? && current_user.primary_club
|
||||||
@user = User.new
|
@user = User.new
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ module Public
|
|||||||
return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!"
|
return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra."
|
redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società."
|
||||||
else
|
else
|
||||||
flash.now[:alert] = @user.errors.full_messages.join(", ")
|
flash.now[:alert] = @user.errors.full_messages.join(", ")
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
module Public
|
module Public
|
||||||
class SessionsController < WebBaseController
|
class SessionsController < WebBaseController
|
||||||
def new
|
def new
|
||||||
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
|
if logged_in? && current_user.primary_club
|
||||||
|
redirect_to public_club_path(current_user.primary_club)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email]&.downcase)
|
user = User.find_by(email: params[:email]&.downcase)
|
||||||
if user&.authenticate(params[:password])
|
if user&.authenticate(params[:password])
|
||||||
session[:user_id] = user.id
|
session[:user_id] = user.id
|
||||||
dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path
|
dest = if user.primary_club
|
||||||
|
public_club_path(user.primary_club)
|
||||||
|
elsif user.manageable_teams.first
|
||||||
|
public_team_dashboard_path(user.manageable_teams.first)
|
||||||
|
else
|
||||||
|
public_new_club_path
|
||||||
|
end
|
||||||
redirect_to dest, notice: "Bentornato!"
|
redirect_to dest, notice: "Bentornato!"
|
||||||
else
|
else
|
||||||
flash.now[:alert] = "Email o password non validi"
|
flash.now[:alert] = "Email o password non validi"
|
||||||
|
|||||||
@@ -1,82 +1,89 @@
|
|||||||
module Public
|
module Public
|
||||||
class TeamsController < WebBaseController
|
class TeamsController < WebBaseController
|
||||||
|
include BrandingAttachments
|
||||||
|
|
||||||
before_action :require_login!
|
before_action :require_login!
|
||||||
|
before_action :set_club, only: %i[new create]
|
||||||
before_action :set_team, only: %i[
|
before_action :set_team, only: %i[
|
||||||
dashboard billing checkout portal invite create_invitation
|
dashboard edit update invite create_invitation
|
||||||
remove_member destroy_invitation
|
assign_self_staff clear_self_staff remove_member destroy_invitation
|
||||||
]
|
]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
redirect_to public_team_dashboard_path(current_user.teams.first) if current_user.teams.any?
|
redirect_to public_new_club_path if current_user.owned_clubs.none?
|
||||||
|
require_club_owner!(@club)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if current_user.user_teams.where(role: "owner").exists?
|
require_club_owner!(@club)
|
||||||
redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra"
|
team = @club.teams.create!(team_params)
|
||||||
return
|
attach_branding_logo(team)
|
||||||
end
|
redirect_to public_club_path(@club), notice: "Squadra «#{team.name}» aggiunta."
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
team = Team.create!(team_params)
|
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||||
UserTeam.create!(user: current_user, team: team, role: "owner")
|
render :new, status: :unprocessable_entity
|
||||||
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
|
end
|
||||||
|
|
||||||
def dashboard
|
def dashboard
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
|
@club = @team.club
|
||||||
@entitlements = @team.entitlements
|
@entitlements = @team.entitlements
|
||||||
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
||||||
@members = @team.user_teams.includes(:user).where.not(role: "owner")
|
@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)
|
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def billing
|
def edit
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
@entitlements = @team.entitlements
|
@club = @team.club
|
||||||
@plans = Plan.ordered
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def checkout
|
def update
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
unless MatchLiveTv.stripe_enabled?
|
@team.assign_attributes(team_params)
|
||||||
redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server"
|
attach_branding_logo(@team)
|
||||||
return
|
@team.save!
|
||||||
end
|
redirect_to public_team_dashboard_path(@team), notice: "Squadra aggiornata."
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
|
@club = @team.club
|
||||||
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url
|
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||||
redirect_to url, allow_other_host: true
|
render :edit, status: :unprocessable_entity
|
||||||
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
|
end
|
||||||
|
|
||||||
def invite
|
def invite
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
@entitlements = @team.entitlements
|
@entitlements = @team.entitlements
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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}."
|
||||||
|
rescue Teams::StaffAssignmentError, Teams::EntitlementError => e
|
||||||
|
redirect_to public_team_dashboard_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."
|
||||||
|
end
|
||||||
|
|
||||||
def create_invitation
|
def create_invitation
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
@entitlements = @team.entitlements
|
@entitlements = @team.entitlements
|
||||||
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
|
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
|
||||||
@entitlements.assert_can_invite!(staff_kind: staff_kind)
|
@entitlements.assert_can_invite!(staff_kind: staff_kind)
|
||||||
|
|
||||||
email = params[:email]&.downcase&.strip
|
email = params[:email]&.downcase&.strip
|
||||||
|
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
|
||||||
token = TeamInvitation.generate_token
|
token = TeamInvitation.generate_token
|
||||||
@team.team_invitations.create!(
|
@team.team_invitations.create!(
|
||||||
email: email,
|
email: email,
|
||||||
@@ -88,19 +95,19 @@ module Public
|
|||||||
@invite_url = join_public_invitation_url(token: token)
|
@invite_url = join_public_invitation_url(token: token)
|
||||||
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
|
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
|
||||||
render :invite
|
render :invite
|
||||||
rescue Teams::EntitlementError => e
|
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
|
||||||
redirect_to public_team_invite_path(@team), alert: e.message
|
redirect_to public_team_invite_path(@team), alert: e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_member
|
def remove_member
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
||||||
ut.destroy!
|
ut.destroy!
|
||||||
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
|
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy_invitation
|
def destroy_invitation
|
||||||
require_team_owner!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
inv = @team.team_invitations.pending.find(params[:invitation_id])
|
inv = @team.team_invitations.pending.find(params[:invitation_id])
|
||||||
inv.destroy!
|
inv.destroy!
|
||||||
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
|
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
|
||||||
@@ -108,12 +115,25 @@ module Public
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_club
|
||||||
|
@club = current_user.owned_clubs.find(params[:club_id])
|
||||||
|
end
|
||||||
|
|
||||||
def set_team
|
def set_team
|
||||||
@team = current_user.teams.find(params[:id])
|
@team = current_user.manageable_teams.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def team_params
|
def team_params
|
||||||
params.require(:team).permit(:name, :sport, :logo_url)
|
p = params.require(:team).permit(:name, :sport, :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?
|
||||||
|
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
|
||||||
|
end
|
||||||
|
if p[:secondary_color].present?
|
||||||
|
p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color])
|
||||||
|
end
|
||||||
|
p
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ module Public
|
|||||||
redirect_to public_login_path, alert: "Accedi per continuare"
|
redirect_to public_login_path, alert: "Accedi per continuare"
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_team_owner!(team)
|
def require_club_owner!(club)
|
||||||
membership = current_user.user_teams.find_by(team: team)
|
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless club.owned_by?(current_user)
|
||||||
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner"
|
end
|
||||||
|
|
||||||
|
def require_club_owner_for_team!(team)
|
||||||
|
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless team.club&.owned_by?(current_user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
27
backend/app/models/club.rb
Normal file
27
backend/app/models/club.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
class Club < ApplicationRecord
|
||||||
|
include Brandable
|
||||||
|
|
||||||
|
has_many :club_memberships, dependent: :destroy
|
||||||
|
has_many :users, through: :club_memberships
|
||||||
|
has_many :teams, dependent: :destroy
|
||||||
|
has_one :subscription, dependent: :destroy
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :sport, presence: true
|
||||||
|
validates :primary_color, presence: true
|
||||||
|
validates :secondary_color, presence: true
|
||||||
|
|
||||||
|
def owner
|
||||||
|
club_memberships.find_by(role: "owner")&.user
|
||||||
|
end
|
||||||
|
|
||||||
|
def owned_by?(user)
|
||||||
|
club_memberships.exists?(user: user, role: "owner")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def branding_parent
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
9
backend/app/models/club_membership.rb
Normal file
9
backend/app/models/club_membership.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class ClubMembership < ApplicationRecord
|
||||||
|
ROLES = %w[owner].freeze
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :club
|
||||||
|
|
||||||
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
validates :user_id, uniqueness: { scope: :club_id }
|
||||||
|
end
|
||||||
43
backend/app/models/concerns/brandable.rb
Normal file
43
backend/app/models/concerns/brandable.rb
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
module Brandable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
HEX_COLOR = /\A#[0-9A-Fa-f]{6}\z/
|
||||||
|
|
||||||
|
included do
|
||||||
|
has_one_attached :logo_file
|
||||||
|
validates :primary_color, format: { with: HEX_COLOR }, allow_blank: true
|
||||||
|
validates :secondary_color, format: { with: HEX_COLOR }, allow_blank: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_primary_color
|
||||||
|
primary_color.presence || default_primary_color
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_secondary_color
|
||||||
|
secondary_color.presence || default_secondary_color
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_logo_url
|
||||||
|
if logo_file.attached?
|
||||||
|
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
|
||||||
|
elsif logo_url.present?
|
||||||
|
logo_url
|
||||||
|
else
|
||||||
|
branding_parent&.effective_logo_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_primary_color
|
||||||
|
"#e53935"
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_secondary_color
|
||||||
|
"#ffffff"
|
||||||
|
end
|
||||||
|
|
||||||
|
def branding_parent
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,11 +2,11 @@ class Subscription < ApplicationRecord
|
|||||||
STATUSES = %w[active trialing past_due canceled incomplete].freeze
|
STATUSES = %w[active trialing past_due canceled incomplete].freeze
|
||||||
ACTIVE_STATUSES = %w[active trialing].freeze
|
ACTIVE_STATUSES = %w[active trialing].freeze
|
||||||
|
|
||||||
belongs_to :team
|
belongs_to :club
|
||||||
belongs_to :plan
|
belongs_to :plan
|
||||||
|
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :team_id, uniqueness: true
|
validates :club_id, uniqueness: true
|
||||||
|
|
||||||
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
class Team < ApplicationRecord
|
class Team < ApplicationRecord
|
||||||
|
include Brandable
|
||||||
|
|
||||||
|
belongs_to :club
|
||||||
has_many :user_teams, dependent: :destroy
|
has_many :user_teams, dependent: :destroy
|
||||||
has_many :users, through: :user_teams
|
has_many :users, through: :user_teams
|
||||||
has_many :matches, dependent: :destroy
|
has_many :matches, dependent: :destroy
|
||||||
has_one :youtube_credential, dependent: :destroy
|
has_one :youtube_credential, dependent: :destroy
|
||||||
has_one :subscription, dependent: :destroy
|
|
||||||
has_many :recordings, dependent: :destroy
|
has_many :recordings, dependent: :destroy
|
||||||
has_many :team_invitations, dependent: :destroy
|
has_many :team_invitations, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
|
|
||||||
|
def subscription
|
||||||
|
club.subscription
|
||||||
|
end
|
||||||
|
|
||||||
def entitlements
|
def entitlements
|
||||||
@entitlements ||= Teams::Entitlements.new(self)
|
Teams::Entitlements.new(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def owner
|
def owner
|
||||||
user_teams.find_by(role: "owner")&.user
|
club&.owner
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def branding_parent
|
||||||
|
club
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_primary_color
|
||||||
|
club&.primary_color.presence || super
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_secondary_color
|
||||||
|
club&.secondary_color.presence || super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
has_many :user_teams, dependent: :destroy
|
has_many :user_teams, dependent: :destroy
|
||||||
has_many :teams, through: :user_teams
|
has_many :teams, through: :user_teams
|
||||||
|
has_many :club_memberships, dependent: :destroy
|
||||||
|
has_many :clubs, through: :club_memberships
|
||||||
|
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
||||||
has_many :stream_sessions, dependent: :nullify
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
|
||||||
|
def manageable_teams
|
||||||
|
staff_ids = teams.select(:id)
|
||||||
|
owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id)
|
||||||
|
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
|
||||||
|
end
|
||||||
|
|
||||||
|
def primary_club
|
||||||
|
owned_clubs.order(:created_at).first
|
||||||
|
end
|
||||||
|
|
||||||
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :role, inclusion: { in: ROLES }
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module Billing
|
module Billing
|
||||||
class AssignPlan
|
class AssignPlan
|
||||||
def self.call(team:, plan_slug:, status: "active", stripe_attrs: {})
|
def self.call(club:, plan_slug:, status: "active", stripe_attrs: {})
|
||||||
plan = Plan[plan_slug]
|
plan = Plan[plan_slug]
|
||||||
sub = team.subscription || team.build_subscription
|
sub = club.subscription || club.build_subscription
|
||||||
attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys)
|
attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys)
|
||||||
sub.assign_attributes(attrs)
|
sub.assign_attributes(attrs)
|
||||||
sub.save!
|
sub.save!
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ module Billing
|
|||||||
class CheckoutSession
|
class CheckoutSession
|
||||||
VALID_PLANS = %w[premium_light premium_full].freeze
|
VALID_PLANS = %w[premium_light premium_full].freeze
|
||||||
|
|
||||||
def initialize(team:, user:, plan_slug: "premium_light")
|
def initialize(club:, user:, plan_slug: "premium_light")
|
||||||
@team = team
|
@club = club
|
||||||
@user = user
|
@user = user
|
||||||
@plan_slug = plan_slug.to_s
|
@plan_slug = plan_slug.to_s
|
||||||
end
|
end
|
||||||
@@ -24,8 +24,8 @@ module Billing
|
|||||||
line_items: [{ price: price_id, quantity: 1 }],
|
line_items: [{ price: price_id, quantity: 1 }],
|
||||||
success_url: success_url,
|
success_url: success_url,
|
||||||
cancel_url: cancel_url,
|
cancel_url: cancel_url,
|
||||||
metadata: { team_id: @team.id, user_id: @user.id, plan_slug: @plan_slug },
|
metadata: { club_id: @club.id, user_id: @user.id, plan_slug: @plan_slug },
|
||||||
subscription_data: { metadata: { team_id: @team.id, plan_slug: @plan_slug } }
|
subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } }
|
||||||
)
|
)
|
||||||
session.url
|
session.url
|
||||||
end
|
end
|
||||||
@@ -51,13 +51,13 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def ensure_customer_id!
|
def ensure_customer_id!
|
||||||
sub = @team.subscription || @team.build_subscription(plan: Plan["free"], status: "active")
|
sub = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
||||||
return sub.stripe_customer_id if sub.stripe_customer_id.present?
|
return sub.stripe_customer_id if sub.stripe_customer_id.present?
|
||||||
|
|
||||||
customer = ::Stripe::Customer.create(
|
customer = ::Stripe::Customer.create(
|
||||||
email: @user.email,
|
email: @user.email,
|
||||||
name: @team.name,
|
name: @club.name,
|
||||||
metadata: { team_id: @team.id }
|
metadata: { club_id: @club.id }
|
||||||
)
|
)
|
||||||
sub.update!(stripe_customer_id: customer.id)
|
sub.update!(stripe_customer_id: customer.id)
|
||||||
customer.id
|
customer.id
|
||||||
@@ -72,7 +72,7 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def dashboard_url
|
def dashboard_url
|
||||||
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
|
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,28 +25,25 @@ module Billing
|
|||||||
private
|
private
|
||||||
|
|
||||||
def handle_checkout_completed(session)
|
def handle_checkout_completed(session)
|
||||||
team_id = metadata_team_id(session)
|
club = resolve_club(session)
|
||||||
return if team_id.blank?
|
return unless club
|
||||||
|
|
||||||
team = Team.find_by(id: team_id)
|
sub = club.subscription || club.build_subscription
|
||||||
return unless team
|
|
||||||
|
|
||||||
sub = team.subscription || team.build_subscription
|
|
||||||
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
|
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
|
||||||
|
|
||||||
if session.subscription.present?
|
if session.subscription.present?
|
||||||
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
|
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
|
||||||
plan_slug = metadata_plan_slug(session) || "premium_light"
|
plan_slug = metadata_plan_slug(session) || "premium_light"
|
||||||
plan_slug = "premium_full" if plan_slug == "premium"
|
plan_slug = "premium_full" if plan_slug == "premium"
|
||||||
sync_subscription(stripe_sub, team: team, plan_slug: plan_slug)
|
sync_subscription(stripe_sub, club: club, plan_slug: plan_slug)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_subscription(stripe_sub, team: nil, plan_slug: nil)
|
def sync_subscription(stripe_sub, club: nil, plan_slug: nil)
|
||||||
team ||= Team.joins(:subscription)
|
club ||= Club.joins(:subscription)
|
||||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||||
team ||= Team.find_by(id: metadata_team_id(stripe_sub))
|
club ||= resolve_club(stripe_sub)
|
||||||
return unless team
|
return unless club
|
||||||
|
|
||||||
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
|
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
|
||||||
plan_slug = "premium_full" if plan_slug == "premium"
|
plan_slug = "premium_full" if plan_slug == "premium"
|
||||||
@@ -56,7 +53,7 @@ module Billing
|
|||||||
period_end = unix_time(stripe_sub.current_period_end)
|
period_end = unix_time(stripe_sub.current_period_end)
|
||||||
|
|
||||||
Billing::AssignPlan.call(
|
Billing::AssignPlan.call(
|
||||||
team: team,
|
club: club,
|
||||||
plan_slug: plan.slug,
|
plan_slug: plan.slug,
|
||||||
status: status,
|
status: status,
|
||||||
stripe_attrs: {
|
stripe_attrs: {
|
||||||
@@ -70,12 +67,12 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def downgrade_to_free(stripe_sub)
|
def downgrade_to_free(stripe_sub)
|
||||||
team = Team.joins(:subscription)
|
club = Club.joins(:subscription)
|
||||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||||
return unless team
|
return unless club
|
||||||
|
|
||||||
Billing::AssignPlan.call(
|
Billing::AssignPlan.call(
|
||||||
team: team,
|
club: club,
|
||||||
plan_slug: "free",
|
plan_slug: "free",
|
||||||
status: "active",
|
status: "active",
|
||||||
stripe_attrs: {
|
stripe_attrs: {
|
||||||
@@ -90,11 +87,21 @@ module Billing
|
|||||||
def mark_past_due(invoice)
|
def mark_past_due(invoice)
|
||||||
return if invoice.subscription.blank?
|
return if invoice.subscription.blank?
|
||||||
|
|
||||||
team = Team.joins(:subscription)
|
club = Club.joins(:subscription)
|
||||||
.find_by(subscriptions: { stripe_subscription_id: invoice.subscription })
|
.find_by(subscriptions: { stripe_subscription_id: invoice.subscription })
|
||||||
return unless team&.subscription
|
return unless club&.subscription
|
||||||
|
|
||||||
team.subscription.update!(status: "past_due")
|
club.subscription.update!(status: "past_due")
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_club(obj)
|
||||||
|
club_id = metadata_club_id(obj)
|
||||||
|
return Club.find_by(id: club_id) if club_id.present?
|
||||||
|
|
||||||
|
team_id = metadata_team_id(obj)
|
||||||
|
return Team.find_by(id: team_id)&.club if team_id.present?
|
||||||
|
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def unix_time(value)
|
def unix_time(value)
|
||||||
@@ -110,6 +117,13 @@ module Billing
|
|||||||
meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil)
|
meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def metadata_club_id(obj)
|
||||||
|
meta = obj.metadata
|
||||||
|
return nil if meta.blank?
|
||||||
|
|
||||||
|
meta["club_id"] || meta[:club_id] || (meta.respond_to?(:club_id) ? meta.club_id : nil)
|
||||||
|
end
|
||||||
|
|
||||||
def metadata_team_id(obj)
|
def metadata_team_id(obj)
|
||||||
meta = obj.metadata
|
meta = obj.metadata
|
||||||
return nil if meta.blank?
|
return nil if meta.blank?
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ module Teams
|
|||||||
|
|
||||||
def initialize(team)
|
def initialize(team)
|
||||||
@team = team
|
@team = team
|
||||||
|
@club = team.club
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
@subscription ||= @team.subscription || ensure_free_subscription!
|
@subscription ||= @club.subscription || ensure_free_subscription!
|
||||||
end
|
end
|
||||||
|
|
||||||
def plan
|
def plan
|
||||||
@@ -59,7 +60,7 @@ module Teams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def members_count_for(kind)
|
def members_count_for(kind)
|
||||||
@team.user_teams.where(role: "member", staff_kind: kind).count
|
@team.user_teams.where(staff_kind: kind).count
|
||||||
end
|
end
|
||||||
|
|
||||||
def pending_invitations_count
|
def pending_invitations_count
|
||||||
@@ -75,8 +76,8 @@ module Teams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def concurrent_streams_used
|
def concurrent_streams_used
|
||||||
StreamSession.joins(:match)
|
StreamSession.joins(match: :team)
|
||||||
.where(matches: { team_id: @team.id })
|
.where(teams: { club_id: @club.id })
|
||||||
.where(status: ACTIVE_STREAM_STATUSES)
|
.where(status: ACTIVE_STREAM_STATUSES)
|
||||||
.count
|
.count
|
||||||
end
|
end
|
||||||
@@ -101,7 +102,7 @@ module Teams
|
|||||||
platform = platform.to_s
|
platform = platform.to_s
|
||||||
unless active?
|
unless active?
|
||||||
raise EntitlementError.new(
|
raise EntitlementError.new(
|
||||||
"Abbonamento non attivo per questa squadra",
|
"Abbonamento non attivo per la società",
|
||||||
code: "subscription_inactive",
|
code: "subscription_inactive",
|
||||||
billing_url: billing_url
|
billing_url: billing_url
|
||||||
)
|
)
|
||||||
@@ -196,7 +197,7 @@ module Teams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def billing_url
|
def billing_url
|
||||||
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/billing"
|
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
|
||||||
end
|
end
|
||||||
|
|
||||||
def staff_manage_url
|
def staff_manage_url
|
||||||
@@ -232,8 +233,8 @@ module Teams
|
|||||||
private
|
private
|
||||||
|
|
||||||
def ensure_free_subscription!
|
def ensure_free_subscription!
|
||||||
Billing::AssignPlan.call(team: @team, plan_slug: "free")
|
Billing::AssignPlan.call(club: @club, plan_slug: "free")
|
||||||
@team.reload.subscription
|
@club.reload.subscription
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
85
backend/app/services/teams/staff_assignment.rb
Normal file
85
backend/app/services/teams/staff_assignment.rb
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
module Teams
|
||||||
|
class StaffAssignmentError < StandardError
|
||||||
|
attr_reader :message
|
||||||
|
|
||||||
|
def initialize(message)
|
||||||
|
@message = message
|
||||||
|
super(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class StaffAssignment
|
||||||
|
def self.call(team:, user:, staff_kind:, membership: nil)
|
||||||
|
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(team:, user:, staff_kind:, membership: nil)
|
||||||
|
@team = team
|
||||||
|
@user = user
|
||||||
|
@staff_kind = staff_kind.to_s
|
||||||
|
@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)
|
||||||
|
|
||||||
|
ut = @membership || @user.user_teams.find_by!(team: @team)
|
||||||
|
return ut if ut.staff_kind == @staff_kind
|
||||||
|
|
||||||
|
ent = Entitlements.new(@team)
|
||||||
|
ent.assert_can_invite!(staff_kind: @staff_kind)
|
||||||
|
ut.update!(staff_kind: @staff_kind)
|
||||||
|
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!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(team:, email:, staff_kind:, 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
|
||||||
|
end
|
||||||
|
|
||||||
|
conflict = conflicting_assignment
|
||||||
|
return unless conflict
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=20">
|
<link rel="stylesheet" href="/marketing.css?v=21">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render "shared/marketing_nav" %>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=20">
|
<link rel="stylesheet" href="/marketing.css?v=21">
|
||||||
<link rel="stylesheet" href="/live.css?v=2">
|
<link rel="stylesheet" href="/live.css?v=2">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<%= link_to "Prezzi", public_pricing_path %>
|
<%= link_to "Prezzi", public_pricing_path %>
|
||||||
<% if logged_in? %>
|
<% if logged_in? %>
|
||||||
<% if current_user.teams.any? %>
|
<% if current_user.primary_club %>
|
||||||
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first) %>
|
· <%= link_to "Società", public_club_path(current_user.primary_club) %>
|
||||||
|
<% elsif current_user.manageable_teams.any? %>
|
||||||
|
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.manageable_teams.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
46
backend/app/views/public/clubs/billing.html.erb
Normal file
46
backend/app/views/public/clubs/billing.html.erb
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<% content_for :title, "Abbonamento — #{@club.name}" %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<div class="wrap" style="padding-top:20px">
|
||||||
|
<h1>Abbonamento</h1>
|
||||||
|
<p>Società: <strong><%= @club.name %></strong> · Piano attuale: <strong><%= @entitlements.plan.name %></strong> (valido per tutte le squadre)</p>
|
||||||
|
|
||||||
|
<div class="plans">
|
||||||
|
<% @plans.each do |plan| %>
|
||||||
|
<div class="plan-card <%= 'featured' if plan.slug == @entitlements.plan.slug %>">
|
||||||
|
<h3><%= plan.name %></h3>
|
||||||
|
<% if plan.slug == "free" %>
|
||||||
|
<div class="plan-price">€0</div>
|
||||||
|
<% elsif plan.slug == "premium_light" %>
|
||||||
|
<div class="plan-price" style="color:#e53935">€40/anno</div>
|
||||||
|
<p style="color:#888;font-size:0.85rem">oppure €5/mese</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="plan-price">€200/anno</div>
|
||||||
|
<p style="color:#888;font-size:0.85rem">oppure €20/mese</p>
|
||||||
|
<% end %>
|
||||||
|
<ul>
|
||||||
|
<li>Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno per squadra</li>
|
||||||
|
<li>Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno per squadra</li>
|
||||||
|
<li>Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea (tutta la società)</li>
|
||||||
|
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
|
||||||
|
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||||
|
</ul>
|
||||||
|
<% if plan.slug == @entitlements.plan.slug %>
|
||||||
|
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||||
|
<% elsif plan.slug == "free" %>
|
||||||
|
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||||
|
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||||
|
<%= link_to "Attiva #{plan.name}", public_club_checkout_path(@club, plan: plan.slug), class: "btn btn-primary" %>
|
||||||
|
<% else %>
|
||||||
|
<p style="color:#888">Stripe non configurato</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
||||||
|
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_club_portal_path(@club), class: "btn btn-secondary" %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<p><%= link_to "← Società", public_club_path(@club) %></p>
|
||||||
|
</div>
|
||||||
17
backend/app/views/public/clubs/edit.html.erb
Normal file
17
backend/app/views/public/clubs/edit.html.erb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<% content_for :title, "Modifica società — #{@club.name}" %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||||
|
<h1>Modifica società</h1>
|
||||||
|
<div class="card">
|
||||||
|
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
|
||||||
|
<%= label_tag "club[name]", "Nome società" %>
|
||||||
|
<%= text_field_tag "club[name]", @club.name, required: true %>
|
||||||
|
<%= label_tag "club[sport]", "Sport principale" %>
|
||||||
|
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %>
|
||||||
|
<%= render "shared/branding_fields", record: @club, legend: "Logo e colori societari" %>
|
||||||
|
<%= submit_tag "Salva", class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p><%= link_to "← Società", public_club_path(@club) %></p>
|
||||||
|
</div>
|
||||||
30
backend/app/views/public/clubs/new.html.erb
Normal file
30
backend/app/views/public/clubs/new.html.erb
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<% content_for :title, "Registra società — Match Live TV" %>
|
||||||
|
<% content_for :meta_description, "Crea la società sportiva e la prima squadra su Match Live TV." %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<section class="auth-page">
|
||||||
|
<h1>La tua società</h1>
|
||||||
|
<p class="auth-lead">Registra il club: potrai aggiungere più squadre (Under 13, Under 15, Serie C…).</p>
|
||||||
|
<div class="card card-wide">
|
||||||
|
<%= form_with url: public_clubs_path, multipart: true do %>
|
||||||
|
<h2 class="form-section-title">Società</h2>
|
||||||
|
<%= label_tag "club[name]", "Nome società / club" %>
|
||||||
|
<%= text_field_tag "club[name]", params.dig(:club, :name), required: true, placeholder: "es. Crazy Volley Rozzano" %>
|
||||||
|
<%= label_tag "club[sport]", "Sport principale" %>
|
||||||
|
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], params.dig(:club, :sport) || "volleyball") %>
|
||||||
|
<%= render "shared/branding_fields", record: Club.new(primary_color: "#e53935", secondary_color: "#ffffff"), legend: "Logo e colori societari" %>
|
||||||
|
|
||||||
|
<h2 class="form-section-title" style="margin-top:24px">Prima squadra</h2>
|
||||||
|
<%= label_tag "first_team[name]", "Nome squadra" %>
|
||||||
|
<%= text_field_tag "first_team[name]", params.dig(:first_team, :name), required: true, placeholder: "es. Under 15" %>
|
||||||
|
|
||||||
|
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
|
||||||
|
<%= select_tag :plan, options_for_select([
|
||||||
|
["Free — 1 staff trasmissione + 1 regia per squadra, 1 live", "free"],
|
||||||
|
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
||||||
|
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
||||||
|
], params[:plan] || "free") %>
|
||||||
|
<%= submit_tag "Crea società", class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
61
backend/app/views/public/clubs/show.html.erb
Normal file
61
backend/app/views/public/clubs/show.html.erb
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<% content_for :title, "#{@club.name} — Società" %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<div class="wrap club-dashboard" style="padding-top:20px">
|
||||||
|
<header class="club-header" style="--club-primary:<%= @club.effective_primary_color %>;--club-secondary:<%= @club.effective_secondary_color %>">
|
||||||
|
<% if @club.effective_logo_url.present? %>
|
||||||
|
<%= image_tag @club.effective_logo_url, alt: "", class: "club-header-logo", width: 56, height: 56 %>
|
||||||
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<h1><%= @club.name %></h1>
|
||||||
|
<% if @entitlements %>
|
||||||
|
<p class="club-meta">
|
||||||
|
Piano: <strong><%= @entitlements.plan.name %></strong>
|
||||||
|
· Squadre: <strong><%= @teams.size %></strong>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:20px 0">
|
||||||
|
<%= link_to "Modifica società", public_edit_club_path(@club), class: "btn btn-secondary" %>
|
||||||
|
<%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %>
|
||||||
|
<%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %>
|
||||||
|
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Squadre</h2>
|
||||||
|
<div class="card">
|
||||||
|
<% if @teams.any? %>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>Squadra</th><th>Branding</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% @teams.each do |team| %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="team-swatch" style="background:<%= team.effective_primary_color %>"></span>
|
||||||
|
<strong><%= team.name %></strong>
|
||||||
|
</td>
|
||||||
|
<td style="color:#888;font-size:0.88rem">
|
||||||
|
<% if team.logo_url.present? || team.logo_file.attached? || team.primary_color.present? %>
|
||||||
|
Personalizzato
|
||||||
|
<% else %>
|
||||||
|
Eredita dalla società
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<%= link_to "Dashboard", public_team_dashboard_path(team) %>
|
||||||
|
·
|
||||||
|
<%= link_to "Modifica", public_edit_team_path(team) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<p style="color:#888">Nessuna squadra. <%= link_to "Aggiungi la prima", public_new_club_team_path(@club) %>.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888;margin-top:24px">Ogni squadra ha il proprio staff trasmissione/regia. L’abbonamento vale per tutta la società.</p>
|
||||||
|
</div>
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0">Esigenze diverse?</h3>
|
<h3 style="margin-top:0">Esigenze diverse?</h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px">Per società con più squadre, tornei o integrazioni custom, contattaci: troviamo insieme l'offerta migliore.</p>
|
<p style="color:#aaa;margin-bottom:16px">Per società con più squadre, tornei o integrazioni custom, contattaci: troviamo insieme l'offerta migliore.</p>
|
||||||
<%= mail_to "info@matchlivetv.eminux.it", "Contattaci", class: "btn btn-primary" %>
|
<%= mail_to "info@matchlive.it", "Contattaci", class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px;text-align:center">
|
<div class="card" style="margin-top:24px;text-align:center">
|
||||||
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlivetv.eminux.it", "Contattaci" %> per un'offerta su misura.</p>
|
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
||||||
|
|||||||
@@ -3,40 +3,73 @@
|
|||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap" style="padding-top:20px">
|
<div class="wrap" style="padding-top:20px">
|
||||||
<h1><%= @team.name %></h1>
|
<p style="color:#888;margin:0 0 8px"><%= link_to @club.name, public_club_path(@club) %> · <%= link_to "Modifica squadra", public_edit_team_path(@team) %></p>
|
||||||
|
<h1 style="display:flex;align-items:center;gap:12px">
|
||||||
|
<% if @team.effective_logo_url.present? %>
|
||||||
|
<%= image_tag @team.effective_logo_url, alt: "", width: 40, height: 40, style: "border-radius:8px" %>
|
||||||
|
<% end %>
|
||||||
|
<%= @team.name %>
|
||||||
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Piano: <strong><%= @entitlements.plan.name %></strong>
|
Piano: <strong><%= @entitlements.plan.name %></strong> (società)
|
||||||
· Staff trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
· Staff trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||||
· Staff regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
· Staff regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||||
· Partite attive: <strong><%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %></strong>
|
· Partite attive: <strong><%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %></strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:20px 0">
|
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:20px 0">
|
||||||
<%= link_to "Abbonamento", public_team_billing_path(@team), class: "btn btn-primary" %>
|
<%= link_to "Abbonamento società", public_club_billing_path(@club), class: "btn btn-primary" %>
|
||||||
<%= link_to "Aggiungi staff", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
<%= link_to "Aggiungi staff", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
||||||
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% owner_staff = @owner_membership&.staff_kind %>
|
||||||
|
<% can_self_transmission = owner_staff != "transmission" && owner_staff != "regia" %>
|
||||||
|
<% can_self_regia = owner_staff != "regia" && owner_staff != "transmission" %>
|
||||||
|
<% if can_self_transmission || can_self_regia %>
|
||||||
|
<div class="card" style="margin-bottom:20px">
|
||||||
|
<h2 style="margin-top:0;font-size:1.1rem">Il tuo account (<%= current_user.email %>)</h2>
|
||||||
|
<p style="color:#aaa;font-size:0.92rem;margin-bottom:12px">
|
||||||
|
Puoi usare le stesse credenziali nell’app mobile. Trasmissione e regia devono essere <strong>email diverse</strong> — non puoi coprire entrambi i ruoli con questo account.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<% if can_self_transmission %>
|
||||||
|
<%= button_to "Sono io — staff trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
<% if can_self_regia %>
|
||||||
|
<%= button_to "Sono io — staff regia", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "regia" }, class: "btn btn-secondary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<h2>Staff attivo</h2>
|
<h2>Staff attivo</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<% if @members.any? %>
|
<% if @staff_memberships.any? %>
|
||||||
<table class="data">
|
<table class="data">
|
||||||
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><th></th></tr></thead>
|
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @members.each do |ut| %>
|
<% @staff_memberships.each do |ut| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= ut.user.name %></td>
|
<td>
|
||||||
|
<%= ut.user.name %>
|
||||||
|
<% if ut.role == "owner" %><span style="color:#888;font-size:0.85rem"> (tu)</span><% end %>
|
||||||
|
</td>
|
||||||
<td><%= ut.user.email %></td>
|
<td><%= ut.user.email %></td>
|
||||||
<td><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
<td><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
||||||
<td>
|
<td>
|
||||||
|
<% if ut.role == "owner" %>
|
||||||
|
<%= button_to "Rimuovi ruolo staff", public_team_clear_self_staff_path(@team), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Rimuovere il ruolo staff dal tuo account?" } } %>
|
||||||
|
<% else %>
|
||||||
<%= button_to "Revoca", public_team_remove_member_path(@team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %>
|
<%= button_to "Revoca", public_team_remove_member_path(@team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %>
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888">Nessun membro staff. <%= link_to "Aggiungi qualcuno", public_team_invite_path(@team) %>.</p>
|
<p style="color:#888">Nessun membro staff. Usa i pulsanti sopra per il tuo account o <%= link_to "invita qualcuno", public_team_invite_path(@team) %>.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
23
backend/app/views/public/teams/edit.html.erb
Normal file
23
backend/app/views/public/teams/edit.html.erb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<% content_for :title, "Modifica squadra — #{@team.name}" %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||||
|
<h1><%= @team.name %></h1>
|
||||||
|
<p style="color:#888">Società: <%= link_to @club.name, public_club_path(@club) %></p>
|
||||||
|
<div class="card">
|
||||||
|
<%= form_with url: public_team_path(@team), method: :patch, multipart: true do %>
|
||||||
|
<%= label_tag "team[name]", "Nome squadra" %>
|
||||||
|
<%= text_field_tag "team[name]", @team.name, required: true %>
|
||||||
|
<%= label_tag "team[sport]", "Sport" %>
|
||||||
|
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @team.sport) %>
|
||||||
|
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %>
|
||||||
|
<p class="branding-hint">
|
||||||
|
Colori società:
|
||||||
|
<span class="color-swatch" style="background:<%= @club.effective_primary_color %>"></span>
|
||||||
|
<span class="color-swatch" style="background:<%= @club.effective_secondary_color %>"></span>
|
||||||
|
</p>
|
||||||
|
<%= submit_tag "Salva squadra", class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p><%= link_to "← Dashboard squadra", public_team_dashboard_path(@team) %></p>
|
||||||
|
</div>
|
||||||
@@ -8,7 +8,21 @@
|
|||||||
· Regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
· Regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<% owner_staff = current_user.user_teams.find_by(team: @team, role: "owner")&.staff_kind %>
|
||||||
|
<% unless owner_staff.present? %>
|
||||||
|
<div class="card" style="max-width:520px;margin-bottom:16px">
|
||||||
|
<p style="margin:0 0 12px;color:#aaa;font-size:0.92rem">
|
||||||
|
<strong><%= current_user.email %></strong> — aggiungi il tuo account senza invito (non potrai essere sia trasmissione sia regia).
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<%= button_to "Sono io — trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||||
|
<%= button_to "Sono io — regia", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "regia" }, class: "btn btn-secondary" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="card" style="max-width:520px">
|
<div class="card" style="max-width:520px">
|
||||||
|
<h2 style="margin-top:0;font-size:1.05rem">Invita un’altra email</h2>
|
||||||
<%= form_with url: public_team_invite_path(@team), method: :post do %>
|
<%= form_with url: public_team_invite_path(@team), method: :post do %>
|
||||||
<%= label_tag :staff_kind, "Ruolo" %>
|
<%= label_tag :staff_kind, "Ruolo" %>
|
||||||
<%= select_tag :staff_kind, options_for_select([
|
<%= select_tag :staff_kind, options_for_select([
|
||||||
@@ -22,7 +36,7 @@
|
|||||||
<% if defined?(@invite_url) && @invite_url.present? %>
|
<% if defined?(@invite_url) && @invite_url.present? %>
|
||||||
<p style="margin-top:16px">Condividi (valido 7 giorni):</p>
|
<p style="margin-top:16px">Condividi (valido 7 giorni):</p>
|
||||||
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
|
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
|
||||||
<p style="color:#888;font-size:0.88rem;margin-top:8px">Il membro dello staff si registra con la stessa email e accetta l'accesso.</p>
|
<p style="color:#888;font-size:0.88rem;margin-top:8px">Il membro dello staff si registra con la stessa email e accetta l'accesso. L'email non può essere già usata per l'altro ruolo (trasmissione vs regia).</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
<% content_for :title, "Crea squadra — Match Live TV" %>
|
<% content_for :title, "Nuova squadra — #{@club.name}" %>
|
||||||
<% content_for :meta_description, "Configura la squadra dopo la registrazione su Match Live TV." %>
|
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<h1>La tua squadra</h1>
|
<h1>Nuova squadra</h1>
|
||||||
<p class="auth-lead">Ultimo passo: nome, sport e piano iniziale.</p>
|
<p class="auth-lead">Società: <strong><%= @club.name %></strong></p>
|
||||||
<div class="card card-wide">
|
<div class="card card-wide">
|
||||||
<%= form_with url: public_teams_path do %>
|
<%= form_with url: public_club_teams_path(@club), multipart: true do %>
|
||||||
<%= label_tag "team[name]", "Nome squadra" %>
|
<%= label_tag "team[name]", "Nome squadra" %>
|
||||||
<%= text_field_tag "team[name]", nil, required: true %>
|
<%= text_field_tag "team[name]", nil, required: true, placeholder: "es. Under 13, Serie C" %>
|
||||||
<%= label_tag "team[sport]", "Sport" %>
|
<%= label_tag "team[sport]", "Sport" %>
|
||||||
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], "volleyball") %>
|
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %>
|
||||||
<%= label_tag :plan, "Piano iniziale" %>
|
<%= render "shared/branding_fields", record: Team.new(club: @club), show_inherit_hint: true, legend: "Override branding (opzionale)" %>
|
||||||
<%= select_tag :plan, options_for_select([
|
<%= submit_tag "Aggiungi squadra", class: "btn btn-primary" %>
|
||||||
["Free — 1 staff trasmissione + 1 regia, 1 live", "free"],
|
|
||||||
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
|
||||||
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
|
||||||
], "free") %>
|
|
||||||
<%= submit_tag "Crea squadra", class: "btn btn-primary" %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="auth-footer"><%= link_to "← Società", public_club_path(@club) %></p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
33
backend/app/views/shared/_branding_fields.html.erb
Normal file
33
backend/app/views/shared/_branding_fields.html.erb
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<% record = local_assigns[:record] %>
|
||||||
|
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
|
||||||
|
|
||||||
|
<fieldset class="branding-fields">
|
||||||
|
<legend><%= local_assigns[:legend] || "Identità visiva" %></legend>
|
||||||
|
|
||||||
|
<% if show_inherit_hint %>
|
||||||
|
<p class="branding-hint">Lascia vuoto per usare logo e colori della società.</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= label_tag "branding[logo_file]", "Logo (immagine)" %>
|
||||||
|
<%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp" %>
|
||||||
|
<% if record.effective_logo_url.present? %>
|
||||||
|
<p class="branding-preview">
|
||||||
|
<%= image_tag record.effective_logo_url, alt: "", width: 64, height: 64, style: "border-radius:8px" %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= label_tag "#{record.model_name.param_key}[logo_url]", "Logo (URL alternativo)" %>
|
||||||
|
<%= text_field_tag "#{record.model_name.param_key}[logo_url]", record.logo_url, placeholder: "https://…" %>
|
||||||
|
|
||||||
|
<%= label_tag "#{record.model_name.param_key}[primary_color]", "Colore principale" %>
|
||||||
|
<div class="color-field">
|
||||||
|
<%= color_field_tag "#{record.model_name.param_key}[primary_color]", record.primary_color.presence || record.effective_primary_color %>
|
||||||
|
<span class="color-swatch" style="background:<%= record.effective_primary_color %>"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= label_tag "#{record.model_name.param_key}[secondary_color]", "Colore secondario" %>
|
||||||
|
<div class="color-field">
|
||||||
|
<%= color_field_tag "#{record.model_name.param_key}[secondary_color]", record.secondary_color.presence || record.effective_secondary_color %>
|
||||||
|
<span class="color-swatch" style="background:<%= record.effective_secondary_color %>"></span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
@@ -24,8 +24,12 @@
|
|||||||
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
|
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<% if logged_in? %>
|
<% if logged_in? %>
|
||||||
<% if current_user.teams.any? %>
|
<% if current_user.primary_club || current_user.manageable_teams.any? %>
|
||||||
<%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first), class: "nav-link-item" %>
|
<% if current_user.primary_club %>
|
||||||
|
<%= link_to "Società", public_club_path(current_user.primary_club), class: "nav-link-item" %>
|
||||||
|
<% elsif current_user.manageable_teams.first %>
|
||||||
|
<%= link_to "Dashboard", public_team_dashboard_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_name
|
def privacy_controller_name
|
||||||
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Gestore del servizio Match Live TV")
|
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_email
|
def privacy_controller_email
|
||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Indirizzo del titolare — configurare PRIVACY_CONTROLLER_ADDRESS")
|
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_vat
|
def privacy_controller_vat
|
||||||
@@ -69,7 +69,7 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mail_from
|
def mail_from
|
||||||
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlivetv.it>")
|
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlive.it>")
|
||||||
end
|
end
|
||||||
|
|
||||||
def password_reset_expiry_hours
|
def password_reset_expiry_hours
|
||||||
|
|||||||
@@ -92,14 +92,24 @@ Rails.application.routes.draw do
|
|||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||||
patch "password/reset", to: "password_resets#update"
|
patch "password/reset", to: "password_resets#update"
|
||||||
get "teams/new", to: "teams#new"
|
get "clubs/new", to: "clubs#new", as: :new_club
|
||||||
post "teams", to: "teams#create"
|
post "clubs", to: "clubs#create"
|
||||||
|
get "clubs/:id", to: "clubs#show", as: :club
|
||||||
|
get "clubs/:id/edit", to: "clubs#edit", as: :edit_club
|
||||||
|
patch "clubs/:id", to: "clubs#update"
|
||||||
|
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
||||||
|
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
||||||
|
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
||||||
|
get "teams/new", to: redirect("/clubs/new")
|
||||||
|
get "clubs/:club_id/teams/new", to: "teams#new", as: :new_club_team
|
||||||
|
post "clubs/:club_id/teams", to: "teams#create", as: :club_teams
|
||||||
get "teams/:id/dashboard", to: "teams#dashboard", as: :team_dashboard
|
get "teams/:id/dashboard", to: "teams#dashboard", as: :team_dashboard
|
||||||
get "teams/:id/billing", to: "teams#billing", as: :team_billing
|
get "teams/:id/edit", to: "teams#edit", as: :edit_team
|
||||||
get "teams/:id/checkout", to: "teams#checkout", as: :team_checkout
|
patch "teams/:id", to: "teams#update"
|
||||||
post "teams/:id/portal", to: "teams#portal", as: :team_portal
|
|
||||||
get "teams/:id/invite", to: "teams#invite", as: :team_invite
|
get "teams/:id/invite", to: "teams#invite", as: :team_invite
|
||||||
post "teams/:id/invite", to: "teams#create_invitation"
|
post "teams/:id/invite", to: "teams#create_invitation"
|
||||||
|
post "teams/:id/staff/self", to: "teams#assign_self_staff", as: :team_assign_self_staff
|
||||||
|
delete "teams/:id/staff/self", to: "teams#clear_self_staff", as: :team_clear_self_staff
|
||||||
delete "teams/:id/members/:user_id", to: "teams#remove_member", as: :team_remove_member
|
delete "teams/:id/members/:user_id", to: "teams#remove_member", as: :team_remove_member
|
||||||
delete "teams/:id/invitations/:invitation_id", to: "teams#destroy_invitation", as: :team_destroy_invitation
|
delete "teams/:id/invitations/:invitation_id", to: "teams#destroy_invitation", as: :team_destroy_invitation
|
||||||
get "join/:token", to: "invitations#show", as: :invitation
|
get "join/:token", to: "invitations#show", as: :invitation
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
class CreateClubsAndBranding < ActiveRecord::Migration[7.2]
|
||||||
|
def up
|
||||||
|
create_table :clubs, id: :uuid do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :sport, null: false, default: "volleyball"
|
||||||
|
t.string :logo_url
|
||||||
|
t.string :primary_color, null: false, default: "#e53935"
|
||||||
|
t.string :secondary_color, null: false, default: "#ffffff"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :club_memberships, id: :uuid do |t|
|
||||||
|
t.references :user, type: :uuid, null: false, foreign_key: true
|
||||||
|
t.references :club, type: :uuid, null: false, foreign_key: true
|
||||||
|
t.string :role, null: false, default: "owner"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :club_memberships, %i[user_id club_id], unique: true
|
||||||
|
|
||||||
|
add_reference :teams, :club, type: :uuid, foreign_key: true
|
||||||
|
add_column :teams, :primary_color, :string
|
||||||
|
add_column :teams, :secondary_color, :string
|
||||||
|
|
||||||
|
migrate_teams_to_clubs
|
||||||
|
|
||||||
|
change_column_null :teams, :club_id, false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
change_column_null :teams, :club_id, true
|
||||||
|
remove_column :teams, :secondary_color
|
||||||
|
remove_column :teams, :primary_color
|
||||||
|
remove_reference :teams, :club
|
||||||
|
drop_table :club_memberships
|
||||||
|
drop_table :clubs
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def migrate_teams_to_clubs
|
||||||
|
say_with_time "Migrazione squadre → società" do
|
||||||
|
Team.find_each do |team|
|
||||||
|
owner_ut = UserTeam.find_by(team: team, role: "owner")
|
||||||
|
next unless owner_ut
|
||||||
|
|
||||||
|
club = Club.create!(
|
||||||
|
name: team.name,
|
||||||
|
sport: team.sport,
|
||||||
|
logo_url: team.logo_url,
|
||||||
|
primary_color: "#e53935",
|
||||||
|
secondary_color: "#ffffff"
|
||||||
|
)
|
||||||
|
ClubMembership.find_or_create_by!(user: owner_ut.user, club: club) do |m|
|
||||||
|
m.role = "owner"
|
||||||
|
end
|
||||||
|
team.update!(club: club)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
class MoveSubscriptionsToClubs < ActiveRecord::Migration[7.2]
|
||||||
|
def up
|
||||||
|
unless column_exists?(:subscriptions, :club_id)
|
||||||
|
add_reference :subscriptions, :club, type: :uuid, foreign_key: true, index: { unique: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
UPDATE subscriptions SET club_id = teams.club_id
|
||||||
|
FROM teams WHERE subscriptions.team_id = teams.id AND subscriptions.club_id IS NULL
|
||||||
|
SQL
|
||||||
|
|
||||||
|
if column_exists?(:subscriptions, :team_id)
|
||||||
|
remove_index :subscriptions, :team_id if index_exists?(:subscriptions, :team_id)
|
||||||
|
remove_reference :subscriptions, :team
|
||||||
|
end
|
||||||
|
|
||||||
|
change_column_null :subscriptions, :club_id, false if column_exists?(:subscriptions, :club_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
add_reference :subscriptions, :team, type: :uuid, foreign_key: true, index: { unique: true } unless column_exists?(:subscriptions, :team_id)
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
UPDATE subscriptions SET team_id = (
|
||||||
|
SELECT teams.id FROM teams WHERE teams.club_id = subscriptions.club_id ORDER BY teams.created_at LIMIT 1
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
remove_reference :subscriptions, :club if column_exists?(:subscriptions, :club_id)
|
||||||
|
change_column_null :subscriptions, :team_id, false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# This migration comes from active_storage (originally 20170806125915)
|
||||||
|
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# Use Active Record's configured type for primary and foreign keys
|
||||||
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.string :content_type
|
||||||
|
t.text :metadata
|
||||||
|
t.string :service_name, null: false
|
||||||
|
t.bigint :byte_size, null: false
|
||||||
|
t.string :checksum
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :key ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||||
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
|
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def primary_and_foreign_key_types
|
||||||
|
config = Rails.configuration.generators
|
||||||
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
|
primary_key_type = setting || :primary_key
|
||||||
|
foreign_key_type = setting || :bigint
|
||||||
|
[ primary_key_type, foreign_key_type ]
|
||||||
|
end
|
||||||
|
end
|
||||||
66
backend/db/schema.rb
generated
66
backend/db/schema.rb
generated
@@ -10,11 +10,39 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
|
create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "record_type", null: false
|
||||||
|
t.uuid "record_id", null: false
|
||||||
|
t.uuid "blob_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||||
|
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "filename", null: false
|
||||||
|
t.string "content_type"
|
||||||
|
t.text "metadata"
|
||||||
|
t.string "service_name", null: false
|
||||||
|
t.bigint "byte_size", null: false
|
||||||
|
t.string "checksum"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "blob_id", null: false
|
||||||
|
t.string "variation_digest", null: false
|
||||||
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "admin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "admin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "username", null: false
|
t.string "username", null: false
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
@@ -23,6 +51,27 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "user_id", null: false
|
||||||
|
t.uuid "club_id", null: false
|
||||||
|
t.string "role", default: "owner", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["club_id"], name: "index_club_memberships_on_club_id"
|
||||||
|
t.index ["user_id", "club_id"], name: "index_club_memberships_on_user_id_and_club_id", unique: true
|
||||||
|
t.index ["user_id"], name: "index_club_memberships_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "clubs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "sport", default: "volleyball", null: false
|
||||||
|
t.string "logo_url"
|
||||||
|
t.string "primary_color", default: "#e53935", null: false
|
||||||
|
t.string "secondary_color", default: "#ffffff", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "stream_session_id", null: false
|
t.uuid "stream_session_id", null: false
|
||||||
t.string "device_role", null: false
|
t.string "device_role", null: false
|
||||||
@@ -133,7 +182,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "team_id", null: false
|
|
||||||
t.uuid "plan_id", null: false
|
t.uuid "plan_id", null: false
|
||||||
t.string "status", default: "active", null: false
|
t.string "status", default: "active", null: false
|
||||||
t.string "stripe_customer_id"
|
t.string "stripe_customer_id"
|
||||||
@@ -143,9 +191,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
t.boolean "cancel_at_period_end", default: false, null: false
|
t.boolean "cancel_at_period_end", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.uuid "club_id", null: false
|
||||||
|
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
|
||||||
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
||||||
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
||||||
t.index ["team_id"], name: "index_subscriptions_on_team_id", unique: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "team_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "team_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
@@ -170,6 +219,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
t.string "logo_url"
|
t.string "logo_url"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.uuid "club_id", null: false
|
||||||
|
t.string "primary_color"
|
||||||
|
t.string "secondary_color"
|
||||||
|
t.index ["club_id"], name: "index_teams_on_club_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
@@ -210,6 +263,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
t.index ["team_id"], name: "index_youtube_credentials_on_team_id"
|
t.index ["team_id"], name: "index_youtube_credentials_on_team_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "club_memberships", "clubs"
|
||||||
|
add_foreign_key "club_memberships", "users"
|
||||||
add_foreign_key "device_states", "stream_sessions"
|
add_foreign_key "device_states", "stream_sessions"
|
||||||
add_foreign_key "matches", "teams"
|
add_foreign_key "matches", "teams"
|
||||||
add_foreign_key "recordings", "stream_sessions"
|
add_foreign_key "recordings", "stream_sessions"
|
||||||
@@ -218,9 +275,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
|||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
|
add_foreign_key "subscriptions", "clubs"
|
||||||
add_foreign_key "subscriptions", "plans"
|
add_foreign_key "subscriptions", "plans"
|
||||||
add_foreign_key "subscriptions", "teams"
|
|
||||||
add_foreign_key "team_invitations", "teams"
|
add_foreign_key "team_invitations", "teams"
|
||||||
|
add_foreign_key "teams", "clubs"
|
||||||
add_foreign_key "user_teams", "teams"
|
add_foreign_key "user_teams", "teams"
|
||||||
add_foreign_key "user_teams", "users"
|
add_foreign_key "user_teams", "users"
|
||||||
add_foreign_key "youtube_credentials", "teams"
|
add_foreign_key "youtube_credentials", "teams"
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
|||||||
u.role = "admin"
|
u.role = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
team = Team.find_or_create_by!(name: "Tigers Volley") do |t|
|
club = Club.find_or_create_by!(name: "Tigers Volley") do |c|
|
||||||
|
c.sport = "volleyball"
|
||||||
|
c.primary_color = "#e53935"
|
||||||
|
c.secondary_color = "#ffffff"
|
||||||
|
end
|
||||||
|
|
||||||
|
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
|
||||||
|
|
||||||
|
team = club.teams.find_or_create_by!(name: "Under 16") do |t|
|
||||||
t.sport = "volleyball"
|
t.sport = "volleyball"
|
||||||
end
|
end
|
||||||
|
|
||||||
UserTeam.find_or_create_by!(user: coach, team: team) { |ut| ut.role = "owner" }
|
|
||||||
UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" }
|
UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" }
|
||||||
|
|
||||||
Billing::AssignPlan.call(team: team, plan_slug: "free") unless team.subscription
|
Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription
|
||||||
|
|
||||||
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m|
|
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m|
|
||||||
m.location = "PalaTigers - Milano"
|
m.location = "PalaTigers - Milano"
|
||||||
@@ -35,4 +42,4 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
|||||||
end
|
end
|
||||||
|
|
||||||
puts "Seed OK: coach@matchlivetv.test / password123"
|
puts "Seed OK: coach@matchlivetv.test / password123"
|
||||||
puts "Team: #{team.name}, Match: #{match.opponent_name}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|||||||
@@ -256,6 +256,17 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; }
|
.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; }
|
||||||
.btn-secondary { background: #252530; color: #fff; }
|
.btn-secondary { background: #252530; color: #fff; }
|
||||||
.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; }
|
.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; }
|
||||||
|
.form-section-title { font-size: 1.05rem; margin: 0 0 12px; color: #e8e8ee; }
|
||||||
|
.branding-fields { border: 1px solid #252530; border-radius: 10px; padding: 16px 18px; margin: 16px 0; }
|
||||||
|
.branding-fields legend { padding: 0 8px; color: #ccc; font-size: 0.9rem; }
|
||||||
|
.branding-hint { color: #888; font-size: 0.88rem; margin: 0 0 12px; }
|
||||||
|
.branding-preview { margin: 8px 0 12px; }
|
||||||
|
.color-field { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.color-swatch { display: inline-block; width: 28px; height: 28px; border-radius: 6px; border: 1px solid #444; vertical-align: middle; }
|
||||||
|
.team-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
|
||||||
|
.club-header { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
|
||||||
|
.club-header-logo { border-radius: 10px; object-fit: contain; }
|
||||||
|
.club-meta { color: #aaa; margin: 4px 0 0; font-size: 0.92rem; }
|
||||||
.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
|
.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||||
.flash.notice { background: #1b3d2a; color: #a5f0b8; }
|
.flash.notice { background: #1b3d2a; color: #a5f0b8; }
|
||||||
.flash.alert { background: #3d1b1b; color: #ffb4b4; }
|
.flash.alert { background: #3d1b1b; color: #ffb4b4; }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe Teams::Entitlements do
|
RSpec.describe Teams::Entitlements do
|
||||||
let(:team) { Team.create!(name: "Test Team", sport: "volleyball") }
|
let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let(:team) { club.teams.create!(name: "Test Team", sport: "volleyball") }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
Billing::AssignPlan.call(team: team, plan_slug: "free")
|
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||||
end
|
end
|
||||||
|
|
||||||
subject(:ent) { described_class.new(team) }
|
subject(:ent) { described_class.new(team) }
|
||||||
@@ -19,7 +20,7 @@ RSpec.describe Teams::Entitlements do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "allows youtube on premium full" do
|
it "allows youtube on premium full" do
|
||||||
Billing::AssignPlan.call(team: team, plan_slug: "premium_full")
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
expect(ent.can_stream_on?("youtube")).to be true
|
expect(ent.can_stream_on?("youtube")).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
38
backend/spec/services/teams/staff_assignment_spec.rb
Normal file
38
backend/spec/services/teams/staff_assignment_spec.rb
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Teams::StaffAssignment do
|
||||||
|
let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let(:team) { club.teams.create!(name: "Test", sport: "volleyball") }
|
||||||
|
let(:owner) { User.create!(name: "Coach", email: "owner@test.com", password: "password123", role: "coach") }
|
||||||
|
|
||||||
|
before do
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||||
|
ClubMembership.create!(user: owner, club: club, role: "owner")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "assigns owner as transmission staff" do
|
||||||
|
membership = UserTeam.create!(user: owner, team: team, role: "member")
|
||||||
|
described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership)
|
||||||
|
expect(membership.reload.staff_kind).to eq("transmission")
|
||||||
|
expect(team.entitlements.staff_count_for("transmission")).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "blocks same email for transmission and regia" do
|
||||||
|
membership = UserTeam.create!(user: owner, team: team, role: "member")
|
||||||
|
described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.call(team: team, user: owner, staff_kind: "regia", membership: membership)
|
||||||
|
}.to raise_error(Teams::StaffAssignmentError, /email diverse/i)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "blocks invite when email already used for other staff kind" do
|
||||||
|
membership = UserTeam.create!(user: owner, team: team, role: "member")
|
||||||
|
described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
Teams::StaffEmailValidator.assert_available!(team: team, email: owner.email, staff_kind: "regia")
|
||||||
|
}.to raise_error(Teams::StaffAssignmentError, /già assegnata/i)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese |
|
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese |
|
||||||
| Embed sito società | No | No | In arrivo |
|
| Embed sito società | No | No | In arrivo |
|
||||||
|
|
||||||
Esigenze custom: contatto `info@matchlivetv.eminux.it`.
|
Esigenze custom: contatto `info@matchlive.it`.
|
||||||
|
|
||||||
## Sito
|
## Sito
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Server:** `eminux@192.168.1.146` (Debian 13)
|
**Server:** `eminux@192.168.1.146` (Debian 13)
|
||||||
**Path deploy:** `~/matchlivetv` (dopo bootstrap opzionale: `/opt/matchlivetv`)
|
**Path deploy:** `~/matchlivetv` (dopo bootstrap opzionale: `/opt/matchlivetv`)
|
||||||
**Ultimo aggiornamento:** 2026-05-25
|
**Ultimo aggiornamento:** 2026-05-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ Il flusso video **non passa da Rails**: telefono → RTMP :1935 → MediaMTX →
|
|||||||
|
|
||||||
## Nginx Proxy Manager (HTTPS)
|
## Nginx Proxy Manager (HTTPS)
|
||||||
|
|
||||||
1. **Proxy Host** → Forward Hostname: `http://192.168.1.146:3000` (dominio es. `matchlivetv.eminux.it`)
|
1. **Proxy Host** → Forward Hostname: `http://192.168.1.146:3000` (dominio principale `www.matchlivetv.it`, redirect apex `matchlivetv.it` → `www`)
|
||||||
2. **Websockets:** ON (obbligatorio per `/cable`)
|
2. **Websockets:** ON (obbligatorio per `/cable`)
|
||||||
3. **Custom location HLS** (spettatori):
|
3. **Custom location HLS** (spettatori):
|
||||||
- Location: `/hls`
|
- Location: `/hls`
|
||||||
- Forward: `http://192.168.1.146:8888`
|
- Forward: `http://192.168.1.146:8888` (in NPM usare proxy pass che **rimuove** il prefisso `/hls`, così MediaMTX riceve `/live/match_{uuid}/index.m3u8`)
|
||||||
- Scheme: http
|
- Scheme: http
|
||||||
- Così l'HLS è `https://matchlivetv.eminux.it/hls/match_{uuid}/index.m3u8`
|
- URL pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8`
|
||||||
4. SSL Let's Encrypt su NPM
|
4. SSL Let's Encrypt su NPM
|
||||||
5. Aggiorna `infra/.env` sul server:
|
5. Aggiorna `infra/.env` sul server:
|
||||||
- `APP_PUBLIC_URL=https://matchlivetv.eminux.it`
|
- `APP_PUBLIC_URL=https://www.matchlivetv.it`
|
||||||
- `HLS_PUBLIC_URL=https://matchlivetv.eminux.it/hls`
|
- `HLS_PUBLIC_URL=https://www.matchlivetv.it/hls`
|
||||||
- `CORS_ORIGINS=https://matchlivetv.eminux.it`
|
- `CORS_ORIGINS=https://www.matchlivetv.it,https://matchlivetv.it`
|
||||||
- `ALLOWED_HOSTS=matchlivetv.eminux.it,192.168.1.146`
|
- `ALLOWED_HOSTS=www.matchlivetv.it,matchlivetv.it,192.168.1.146`
|
||||||
- `MEDIAMTX_RTMP_URL=rtmp://matchlivetv.eminux.it:1935`
|
- `MEDIAMTX_RTMP_URL=rtmp://www.matchlivetv.it:1935`
|
||||||
- `YOUTUBE_REDIRECT_URI=https://matchlivetv.eminux.it/api/v1/youtube/callback` (solo tier premium)
|
- `YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback` (solo tier premium)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ YOUTUBE_CLIENT_ID=
|
|||||||
YOUTUBE_CLIENT_SECRET=
|
YOUTUBE_CLIENT_SECRET=
|
||||||
YOUTUBE_REDIRECT_URI=http://localhost:3000/api/v1/youtube/callback
|
YOUTUBE_REDIRECT_URI=http://localhost:3000/api/v1/youtube/callback
|
||||||
|
|
||||||
PRIVACY_CONTROLLER_NAME=Gestore del servizio Match Live TV (sviluppo)
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Indirizzo di sviluppo
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlive.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
|||||||
MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32
|
MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||||
|
|
||||||
# RTMP pubblico per telefoni (IP WAN o DDNS + porta 1935)
|
# RTMP pubblico per telefoni (IP WAN o DDNS + porta 1935)
|
||||||
MEDIAMTX_RTMP_URL=rtmp://YOUR_PUBLIC_IP_OR_DOMAIN:1935
|
MEDIAMTX_RTMP_URL=rtmp://www.matchlivetv.it:1935
|
||||||
|
|
||||||
# Dominio pubblico (Nginx Proxy Manager → Rails :3000)
|
# Dominio pubblico (Nginx Proxy Manager → Rails :3000)
|
||||||
APP_PUBLIC_URL=https://matchlivetv.yourdomain.com
|
APP_PUBLIC_URL=https://www.matchlivetv.it
|
||||||
HLS_PUBLIC_URL=https://matchlivetv.yourdomain.com/hls
|
HLS_PUBLIC_URL=https://www.matchlivetv.it/hls
|
||||||
|
|
||||||
CORS_ORIGINS=https://matchlivetv.yourdomain.com
|
CORS_ORIGINS=https://www.matchlivetv.it,https://matchlivetv.it
|
||||||
ALLOWED_HOSTS=matchlivetv.yourdomain.com,192.168.1.146
|
ALLOWED_HOSTS=www.matchlivetv.it,matchlivetv.it,192.168.1.146,localhost
|
||||||
YOUTUBE_REDIRECT_URI=https://matchlivetv.yourdomain.com/api/v1/youtube/callback
|
YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback
|
||||||
|
|
||||||
# YouTube OAuth (opzionale)
|
# YouTube OAuth (opzionale)
|
||||||
YOUTUBE_CLIENT_ID=
|
YOUTUBE_CLIENT_ID=
|
||||||
@@ -30,13 +30,13 @@ STRIPE_PREMIUM_FULL_PRICE_ID=
|
|||||||
RAILS_LOG_LEVEL=info
|
RAILS_LOG_LEVEL=info
|
||||||
|
|
||||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||||
PRIVACY_CONTROLLER_NAME=Ragione sociale / nome titolare
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Indirizzo sede legale
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlive.it>
|
||||||
SMTP_ADDRESS=
|
SMTP_ADDRESS=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ services:
|
|||||||
- ./slates:/slates:ro
|
- ./slates:/slates:ro
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
command: /mediamtx.yml
|
command: /mediamtx.yml
|
||||||
|
# Immagine distroless: niente shell/wget/curl; API :9997 non esposta sull'host
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:9997/v3/config/global/get"]
|
test: ["CMD", "/mediamtx", "--help"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
rails:
|
rails:
|
||||||
build:
|
build:
|
||||||
@@ -70,6 +72,17 @@ services:
|
|||||||
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
|
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
|
||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
||||||
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
||||||
|
SMTP_ADDRESS: ${SMTP_ADDRESS:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SMTP_AUTH: ${SMTP_AUTH:-plain}
|
||||||
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
RAILS_LOG_TO_STDOUT: "true"
|
RAILS_LOG_TO_STDOUT: "true"
|
||||||
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
|
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ services:
|
|||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
command: /mediamtx.yml
|
command: /mediamtx.yml
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9997/v3/config/global/get"]
|
test: ["CMD", "/mediamtx", "--help"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
rails:
|
rails:
|
||||||
build:
|
build:
|
||||||
@@ -64,6 +65,10 @@ services:
|
|||||||
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
||||||
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback}
|
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback}
|
||||||
RAILS_LOG_TO_STDOUT: "true"
|
RAILS_LOG_TO_STDOUT: "true"
|
||||||
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
||||||
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Reference in New Issue
Block a user