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:
2026-05-27 09:16:24 +02:00
parent 471291b2c4
commit 4083bc5dee
53 changed files with 1120 additions and 197 deletions

View File

@@ -2,7 +2,7 @@ class SessionChannel < ApplicationCable::Channel
include CableBroadcastable
def subscribed
session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.merge(current_user.manageable_teams)
.find(params[:session_id])
stream_for session
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")

View File

@@ -40,11 +40,11 @@ module Api
private
def set_team
@team = current_user.teams.find(params[:team_id])
@team = current_user.manageable_teams.find(params[:team_id])
end
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
def match_params

View File

@@ -4,7 +4,7 @@ module Api
before_action :set_session, except: :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
render json: session_json(session), status: :created
end
@@ -102,7 +102,7 @@ module Api
def set_session
@session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.merge(current_user.manageable_teams)
.find(params[:id])
end

View File

@@ -2,30 +2,32 @@ module Api
module V1
class TeamsController < BaseController
def index
teams = current_user.teams.includes(:matches)
teams = current_user.manageable_teams.includes(:club, :matches)
render json: teams.map { |t| team_json(t) }
end
def show
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
render json: team_json(team, detail: true)
end
def create
if current_user.user_teams.where(role: "owner").count >= 1
if current_user.owned_clubs.exists?
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
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
Billing::AssignPlan.call(team: team, plan_slug: "free")
club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball",
primary_color: "#e53935", secondary_color: "#ffffff")
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
end
def recordings
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
unless team.entitlements.can_access_recordings?
return render json: {
error: "Archivio gare disponibile con Premium Light o Full",
@@ -39,13 +41,13 @@ module Api
end
def update
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
team.update!(team_params)
render json: team_json(team)
end
def add_member
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
team.entitlements.assert_can_invite!
member = User.find(params[:user_id])
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
@@ -53,7 +55,7 @@ module Api
end
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.destroy!
head :no_content
@@ -71,7 +73,11 @@ module Api
id: team.id,
name: team.name,
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?,
plan_slug: ent.plan.slug,
plan_name: ent.plan.name,

View File

@@ -2,14 +2,14 @@ module Api
module V1
class YoutubeController < BaseController
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!
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url }
end
def callback
team = current_user.teams.find(params[:state])
team = current_user.manageable_teams.find(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
cred = team.youtube_credential || team.build_youtube_credential
cred.update!(

View 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

View 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

View File

@@ -1,7 +1,7 @@
module Public
class RegistrationsController < WebBaseController
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
end
@@ -23,7 +23,7 @@ module Public
return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!"
end
end
redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra."
redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società."
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity

View File

@@ -1,14 +1,22 @@
module Public
class SessionsController < WebBaseController
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
def create
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
session[:user_id] = user.id
dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path
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!"
else
flash.now[:alert] = "Email o password non validi"

View File

@@ -1,82 +1,89 @@
module Public
class TeamsController < WebBaseController
include BrandingAttachments
before_action :require_login!
before_action :set_club, only: %i[new create]
before_action :set_team, only: %i[
dashboard billing checkout portal invite create_invitation
remove_member destroy_invitation
dashboard edit update invite create_invitation
assign_self_staff clear_self_staff remove_member destroy_invitation
]
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
def create
if current_user.user_teams.where(role: "owner").exists?
redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra"
return
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(team: team, plan_slug: plan)
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
redirect_to public_team_checkout_path(team, plan: plan)
else
redirect_to public_team_dashboard_path(team), notice: "Squadra creata con piano Free"
end
require_club_owner!(@club)
team = @club.teams.create!(team_params)
attach_branding_logo(team)
redirect_to public_club_path(@club), notice: "Squadra «#{team.name}» aggiunta."
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def dashboard
require_team_owner!(@team)
require_club_owner_for_team!(@team)
@club = @team.club
@entitlements = @team.entitlements
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
@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)
end
def billing
require_team_owner!(@team)
@entitlements = @team.entitlements
@plans = Plan.ordered
def edit
require_club_owner_for_team!(@team)
@club = @team.club
end
def checkout
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server"
return
end
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url
redirect_to url, allow_other_host: true
end
def portal
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Portale pagamenti non configurato"
return
end
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user).portal_url
redirect_to url, allow_other_host: true
def update
require_club_owner_for_team!(@team)
@team.assign_attributes(team_params)
attach_branding_logo(@team)
@team.save!
redirect_to public_team_dashboard_path(@team), notice: "Squadra aggiornata."
rescue ActiveRecord::RecordInvalid => e
@club = @team.club
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
def invite
require_team_owner!(@team)
require_club_owner_for_team!(@team)
@entitlements = @team.entitlements
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
require_team_owner!(@team)
require_club_owner_for_team!(@team)
@entitlements = @team.entitlements
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
@entitlements.assert_can_invite!(staff_kind: staff_kind)
email = params[:email]&.downcase&.strip
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
token = TeamInvitation.generate_token
@team.team_invitations.create!(
email: email,
@@ -88,19 +95,19 @@ module Public
@invite_url = join_public_invitation_url(token: token)
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
render :invite
rescue Teams::EntitlementError => e
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
redirect_to public_team_invite_path(@team), alert: e.message
end
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.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
end
def destroy_invitation
require_team_owner!(@team)
require_club_owner_for_team!(@team)
inv = @team.team_invitations.pending.find(params[:invitation_id])
inv.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
@@ -108,12 +115,25 @@ module Public
private
def set_club
@club = current_user.owned_clubs.find(params[:club_id])
end
def set_team
@team = current_user.teams.find(params[:id])
@team = current_user.manageable_teams.find(params[:id])
end
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

View File

@@ -10,9 +10,12 @@ module Public
redirect_to public_login_path, alert: "Accedi per continuare"
end
def require_team_owner!(team)
membership = current_user.user_teams.find_by(team: team)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner"
def require_club_owner!(club)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless club.owned_by?(current_user)
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

View 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

View 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

View 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

View File

@@ -2,11 +2,11 @@ class Subscription < ApplicationRecord
STATUSES = %w[active trialing past_due canceled incomplete].freeze
ACTIVE_STATUSES = %w[active trialing].freeze
belongs_to :team
belongs_to :club
belongs_to :plan
validates :status, inclusion: { in: STATUSES }
validates :team_id, uniqueness: true
validates :club_id, uniqueness: true
scope :active, -> { where(status: ACTIVE_STATUSES) }

View File

@@ -1,19 +1,39 @@
class Team < ApplicationRecord
include Brandable
belongs_to :club
has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams
has_many :matches, dependent: :destroy
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy
has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy
validates :name, presence: true
def subscription
club.subscription
end
def entitlements
@entitlements ||= Teams::Entitlements.new(self)
Teams::Entitlements.new(self)
end
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

View File

@@ -5,8 +5,21 @@ class User < ApplicationRecord
has_many :user_teams, dependent: :destroy
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
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 :name, presence: true
validates :role, inclusion: { in: ROLES }

View File

@@ -1,8 +1,8 @@
module Billing
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]
sub = team.subscription || team.build_subscription
sub = club.subscription || club.build_subscription
attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys)
sub.assign_attributes(attrs)
sub.save!

View File

@@ -3,8 +3,8 @@ module Billing
class CheckoutSession
VALID_PLANS = %w[premium_light premium_full].freeze
def initialize(team:, user:, plan_slug: "premium_light")
@team = team
def initialize(club:, user:, plan_slug: "premium_light")
@club = club
@user = user
@plan_slug = plan_slug.to_s
end
@@ -24,8 +24,8 @@ module Billing
line_items: [{ price: price_id, quantity: 1 }],
success_url: success_url,
cancel_url: cancel_url,
metadata: { team_id: @team.id, user_id: @user.id, plan_slug: @plan_slug },
subscription_data: { metadata: { team_id: @team.id, plan_slug: @plan_slug } }
metadata: { club_id: @club.id, user_id: @user.id, plan_slug: @plan_slug },
subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } }
)
session.url
end
@@ -51,13 +51,13 @@ module Billing
end
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?
customer = ::Stripe::Customer.create(
email: @user.email,
name: @team.name,
metadata: { team_id: @team.id }
name: @club.name,
metadata: { club_id: @club.id }
)
sub.update!(stripe_customer_id: customer.id)
customer.id
@@ -72,7 +72,7 @@ module Billing
end
def dashboard_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}"
end
end
end

View File

@@ -25,28 +25,25 @@ module Billing
private
def handle_checkout_completed(session)
team_id = metadata_team_id(session)
return if team_id.blank?
club = resolve_club(session)
return unless club
team = Team.find_by(id: team_id)
return unless team
sub = team.subscription || team.build_subscription
sub = club.subscription || club.build_subscription
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
if session.subscription.present?
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
plan_slug = metadata_plan_slug(session) || "premium_light"
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
def sync_subscription(stripe_sub, team: nil, plan_slug: nil)
team ||= Team.joins(:subscription)
def sync_subscription(stripe_sub, club: nil, plan_slug: nil)
club ||= Club.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
team ||= Team.find_by(id: metadata_team_id(stripe_sub))
return unless team
club ||= resolve_club(stripe_sub)
return unless club
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
plan_slug = "premium_full" if plan_slug == "premium"
@@ -56,7 +53,7 @@ module Billing
period_end = unix_time(stripe_sub.current_period_end)
Billing::AssignPlan.call(
team: team,
club: club,
plan_slug: plan.slug,
status: status,
stripe_attrs: {
@@ -70,12 +67,12 @@ module Billing
end
def downgrade_to_free(stripe_sub)
team = Team.joins(:subscription)
club = Club.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
return unless team
return unless club
Billing::AssignPlan.call(
team: team,
club: club,
plan_slug: "free",
status: "active",
stripe_attrs: {
@@ -90,11 +87,21 @@ module Billing
def mark_past_due(invoice)
return if invoice.subscription.blank?
team = Team.joins(:subscription)
club = Club.joins(: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
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)
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)
meta = obj.metadata
return nil if meta.blank?

View File

@@ -4,10 +4,11 @@ module Teams
def initialize(team)
@team = team
@club = team.club
end
def subscription
@subscription ||= @team.subscription || ensure_free_subscription!
@subscription ||= @club.subscription || ensure_free_subscription!
end
def plan
@@ -59,7 +60,7 @@ module Teams
end
def members_count_for(kind)
@team.user_teams.where(role: "member", staff_kind: kind).count
@team.user_teams.where(staff_kind: kind).count
end
def pending_invitations_count
@@ -75,8 +76,8 @@ module Teams
end
def concurrent_streams_used
StreamSession.joins(:match)
.where(matches: { team_id: @team.id })
StreamSession.joins(match: :team)
.where(teams: { club_id: @club.id })
.where(status: ACTIVE_STREAM_STATUSES)
.count
end
@@ -101,7 +102,7 @@ module Teams
platform = platform.to_s
unless active?
raise EntitlementError.new(
"Abbonamento non attivo per questa squadra",
"Abbonamento non attivo per la società",
code: "subscription_inactive",
billing_url: billing_url
)
@@ -196,7 +197,7 @@ module Teams
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/billing"
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def staff_manage_url
@@ -232,8 +233,8 @@ module Teams
private
def ensure_free_subscription!
Billing::AssignPlan.call(team: @team, plan_slug: "free")
@team.reload.subscription
Billing::AssignPlan.call(club: @club, plan_slug: "free")
@club.reload.subscription
end
end
end

View 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

View File

@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= 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="/marketing.css?v=20">
<link rel="stylesheet" href="/marketing.css?v=21">
</head>
<body>
<%= render "shared/marketing_nav" %>

View File

@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= 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="/marketing.css?v=20">
<link rel="stylesheet" href="/marketing.css?v=21">
<link rel="stylesheet" href="/live.css?v=2">
<%= yield :head %>
</head>

View File

@@ -36,8 +36,10 @@
<nav>
<%= link_to "Prezzi", public_pricing_path %>
<% if logged_in? %>
<% if current_user.teams.any? %>
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first) %>
<% if current_user.primary_club %>
· <%= 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 %>
· <%= 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 %>

View 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>

View 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>

View 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>

View 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. Labbonamento vale per tutta la società.</p>
</div>

View File

@@ -69,6 +69,6 @@
<div class="card" style="margin-top:32px;text-align:center">
<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>
<%= mail_to "info@matchlivetv.eminux.it", "Contattaci", class: "btn btn-primary" %>
<%= mail_to "info@matchlive.it", "Contattaci", class: "btn btn-primary" %>
</div>
</div>

View File

@@ -39,7 +39,7 @@
</div>
<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>
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>

View File

@@ -3,40 +3,73 @@
<% content_for :robots, "noindex, nofollow" %>
<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>
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 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>
</p>
<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 "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
</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 nellapp 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>
<div class="card">
<% if @members.any? %>
<% if @staff_memberships.any? %>
<table class="data">
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><th></th></tr></thead>
<tbody>
<% @members.each do |ut| %>
<% @staff_memberships.each do |ut| %>
<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.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
<td>
<%= 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}?" } } %>
<% 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}?" } } %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% 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 %>
</div>

View 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>

View File

@@ -8,7 +8,21 @@
· Regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
</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">
<h2 style="margin-top:0;font-size:1.05rem">Invita unaltra email</h2>
<%= form_with url: public_team_invite_path(@team), method: :post do %>
<%= label_tag :staff_kind, "Ruolo" %>
<%= select_tag :staff_kind, options_for_select([
@@ -22,7 +36,7 @@
<% if defined?(@invite_url) && @invite_url.present? %>
<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>
<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 %>
</div>
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>

View File

@@ -1,23 +1,18 @@
<% content_for :title, "Crea squadra — Match Live TV" %>
<% content_for :meta_description, "Configura la squadra dopo la registrazione su Match Live TV." %>
<% content_for :title, "Nuova squadra — #{@club.name}" %>
<% content_for :robots, "noindex, nofollow" %>
<section class="auth-page">
<h1>La tua squadra</h1>
<p class="auth-lead">Ultimo passo: nome, sport e piano iniziale.</p>
<h1>Nuova squadra</h1>
<p class="auth-lead">Società: <strong><%= @club.name %></strong></p>
<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" %>
<%= 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" %>
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], "volleyball") %>
<%= label_tag :plan, "Piano iniziale" %>
<%= select_tag :plan, options_for_select([
["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" %>
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %>
<%= render "shared/branding_fields", record: Team.new(club: @club), show_inherit_hint: true, legend: "Override branding (opzionale)" %>
<%= submit_tag "Aggiungi squadra", class: "btn btn-primary" %>
<% end %>
</div>
<p class="auth-footer"><%= link_to "← Società", public_club_path(@club) %></p>
</section>

View 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>

View File

@@ -24,8 +24,12 @@
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
<div class="nav-actions">
<% if logged_in? %>
<% if current_user.teams.any? %>
<%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first), class: "nav-link-item" %>
<% if current_user.primary_club || current_user.manageable_teams.any? %>
<% 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 %>
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
<% else %>

View File

@@ -53,15 +53,15 @@ module MatchLiveTv
end
def privacy_controller_name
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Gestore del servizio Match Live TV")
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
end
def privacy_controller_email
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
end
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
def privacy_controller_vat
@@ -69,7 +69,7 @@ module MatchLiveTv
end
def mail_from
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlivetv.it>")
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlive.it>")
end
def password_reset_expiry_hours

View File

@@ -92,14 +92,24 @@ Rails.application.routes.draw do
post "password/forgot", to: "password_resets#create"
get "password/reset", to: "password_resets#edit", as: :password_reset
patch "password/reset", to: "password_resets#update"
get "teams/new", to: "teams#new"
post "teams", to: "teams#create"
get "clubs/new", to: "clubs#new", as: :new_club
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/billing", to: "teams#billing", as: :team_billing
get "teams/:id/checkout", to: "teams#checkout", as: :team_checkout
post "teams/:id/portal", to: "teams#portal", as: :team_portal
get "teams/:id/edit", to: "teams#edit", as: :edit_team
patch "teams/:id", to: "teams#update"
get "teams/:id/invite", to: "teams#invite", as: :team_invite
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/invitations/:invitation_id", to: "teams#destroy_invitation", as: :team_destroy_invitation
get "join/:token", to: "invitations#show", as: :invitation

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -10,11 +10,39 @@
#
# 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
enable_extension "pgcrypto"
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|
t.string "username", 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
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|
t.uuid "stream_session_id", null: false
t.string "device_role", null: false
@@ -133,7 +182,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
end
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.string "status", default: "active", null: false
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.datetime "created_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 ["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
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.datetime "created_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
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"
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 "matches", "teams"
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_sessions", "matches"
add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "clubs"
add_foreign_key "subscriptions", "plans"
add_foreign_key "subscriptions", "teams"
add_foreign_key "team_invitations", "teams"
add_foreign_key "teams", "clubs"
add_foreign_key "user_teams", "teams"
add_foreign_key "user_teams", "users"
add_foreign_key "youtube_credentials", "teams"

View File

@@ -16,14 +16,21 @@ admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
u.role = "admin"
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"
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" }
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|
m.location = "PalaTigers - Milano"
@@ -35,4 +42,4 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
end
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}"

View File

@@ -256,6 +256,17 @@ body.nav-menu-open { overflow: hidden; }
.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; }
.btn-secondary { background: #252530; 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.notice { background: #1b3d2a; color: #a5f0b8; }
.flash.alert { background: #3d1b1b; color: #ffb4b4; }

View File

@@ -1,11 +1,12 @@
require "rails_helper"
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
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
subject(:ent) { described_class.new(team) }
@@ -19,7 +20,7 @@ RSpec.describe Teams::Entitlements do
end
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
end

View 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