diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index 2d81bea..1fe4381 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -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") diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index e38ed37..0d78f7a 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 2c191e9..bfb6d61 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 656d4e6..fab2c89 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -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, diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb index 6268701..507787a 100644 --- a/backend/app/controllers/api/v1/youtube_controller.rb +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -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!( diff --git a/backend/app/controllers/concerns/branding_attachments.rb b/backend/app/controllers/concerns/branding_attachments.rb new file mode 100644 index 0000000..54ef516 --- /dev/null +++ b/backend/app/controllers/concerns/branding_attachments.rb @@ -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 diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb new file mode 100644 index 0000000..c438272 --- /dev/null +++ b/backend/app/controllers/public/clubs_controller.rb @@ -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 diff --git a/backend/app/controllers/public/registrations_controller.rb b/backend/app/controllers/public/registrations_controller.rb index b92b811..d6c0c93 100644 --- a/backend/app/controllers/public/registrations_controller.rb +++ b/backend/app/controllers/public/registrations_controller.rb @@ -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 diff --git a/backend/app/controllers/public/sessions_controller.rb b/backend/app/controllers/public/sessions_controller.rb index 3759be3..8240ef6 100644 --- a/backend/app/controllers/public/sessions_controller.rb +++ b/backend/app/controllers/public/sessions_controller.rb @@ -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" diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index fd64d9b..49a0127 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -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 diff --git a/backend/app/controllers/public/web_base_controller.rb b/backend/app/controllers/public/web_base_controller.rb index ccd966d..5806c61 100644 --- a/backend/app/controllers/public/web_base_controller.rb +++ b/backend/app/controllers/public/web_base_controller.rb @@ -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 diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb new file mode 100644 index 0000000..198b60d --- /dev/null +++ b/backend/app/models/club.rb @@ -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 diff --git a/backend/app/models/club_membership.rb b/backend/app/models/club_membership.rb new file mode 100644 index 0000000..053280a --- /dev/null +++ b/backend/app/models/club_membership.rb @@ -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 diff --git a/backend/app/models/concerns/brandable.rb b/backend/app/models/concerns/brandable.rb new file mode 100644 index 0000000..540dce4 --- /dev/null +++ b/backend/app/models/concerns/brandable.rb @@ -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 diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb index 1ad36cf..f853356 100644 --- a/backend/app/models/subscription.rb +++ b/backend/app/models/subscription.rb @@ -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) } diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index f56281b..0c0dce8 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -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 diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 3e00859..6c0180f 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -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 } diff --git a/backend/app/services/billing/assign_plan.rb b/backend/app/services/billing/assign_plan.rb index 0814181..c2ade85 100644 --- a/backend/app/services/billing/assign_plan.rb +++ b/backend/app/services/billing/assign_plan.rb @@ -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! diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb index ef80376..9a257df 100644 --- a/backend/app/services/billing/stripe/checkout_session.rb +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -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 diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb index 35440f5..36ad5a5 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -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? diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb index 8afbeef..67ed8b8 100644 --- a/backend/app/services/teams/entitlements.rb +++ b/backend/app/services/teams/entitlements.rb @@ -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 diff --git a/backend/app/services/teams/staff_assignment.rb b/backend/app/services/teams/staff_assignment.rb new file mode 100644 index 0000000..30538e7 --- /dev/null +++ b/backend/app/services/teams/staff_assignment.rb @@ -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 diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index afab5b9..78e4778 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= render "shared/marketing_nav" %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 027cca7..bd4a513 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= yield :head %> diff --git a/backend/app/views/layouts/public.html.erb b/backend/app/views/layouts/public.html.erb index 5b94f43..e311c46 100644 --- a/backend/app/views/layouts/public.html.erb +++ b/backend/app/views/layouts/public.html.erb @@ -36,8 +36,10 @@