Billing Stripe, link regia mobile e staff solo trasmissione.
Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
76
backend/app/services/billing/stripe/change_plan.rb
Normal file
76
backend/app/services/billing/stripe/change_plan.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class ChangePlan
|
||||
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
|
||||
|
||||
def self.call(club:, plan_slug:)
|
||||
new(club: club, plan_slug: plan_slug).call
|
||||
end
|
||||
|
||||
def initialize(club:, plan_slug:)
|
||||
@club = club
|
||||
@plan_slug = plan_slug.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
||||
|
||||
sub = @club.subscription
|
||||
raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank?
|
||||
raise "Sei già su questo piano" if sub.plan.slug == @plan_slug
|
||||
|
||||
price_id = Plan[@plan_slug].stripe_price_id.presence || stripe_price_id_for(@plan_slug)
|
||||
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||
item_id = stripe_sub.items.data.first.id
|
||||
|
||||
updated = ::Stripe::Subscription.update(
|
||||
stripe_sub.id,
|
||||
items: [{ id: item_id, price: price_id }],
|
||||
metadata: { club_id: @club.id, plan_slug: @plan_slug },
|
||||
proration_behavior: "create_prorations"
|
||||
)
|
||||
|
||||
apply_local_plan!(updated)
|
||||
updated
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_price_id_for(slug)
|
||||
case slug
|
||||
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
|
||||
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
|
||||
end
|
||||
end
|
||||
|
||||
def apply_local_plan!(stripe_sub)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
Billing::AssignPlan.call(
|
||||
club: @club,
|
||||
plan_slug: @plan_slug,
|
||||
status: map_status(stripe_sub.status),
|
||||
stripe_attrs: {
|
||||
stripe_customer_id: stripe_sub.customer,
|
||||
stripe_subscription_id: stripe_sub.id,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def map_status(stripe_status)
|
||||
case stripe_status
|
||||
when "trialing" then "trialing"
|
||||
when "active" then "active"
|
||||
when "past_due", "unpaid" then "past_due"
|
||||
when "canceled", "incomplete_expired" then "canceled"
|
||||
else "incomplete"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,11 +64,15 @@ module Billing
|
||||
end
|
||||
|
||||
def success_url
|
||||
"#{dashboard_url}?checkout=success"
|
||||
"#{billing_url}?checkout=success"
|
||||
end
|
||||
|
||||
def cancel_url
|
||||
"#{dashboard_url}?checkout=canceled"
|
||||
"#{billing_url}?checkout=canceled"
|
||||
end
|
||||
|
||||
def billing_url
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
|
||||
end
|
||||
|
||||
def dashboard_url
|
||||
|
||||
90
backend/app/services/billing/stripe/record_payment.rb
Normal file
90
backend/app/services/billing/stripe/record_payment.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class RecordPayment
|
||||
def self.call(stripe_invoice:, club: nil)
|
||||
new(stripe_invoice: stripe_invoice, club: club).call
|
||||
end
|
||||
|
||||
def initialize(stripe_invoice:, club: nil)
|
||||
@stripe_invoice = stripe_invoice
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
@club ||= resolve_club
|
||||
return unless @club
|
||||
|
||||
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
|
||||
payment.assign_attributes(
|
||||
club: @club,
|
||||
stripe_payment_intent_id: payment_intent_id,
|
||||
stripe_charge_id: charge_id,
|
||||
amount_cents: @stripe_invoice.amount_paid,
|
||||
currency: @stripe_invoice.currency,
|
||||
status: map_status(@stripe_invoice.status),
|
||||
plan_slug: plan_slug,
|
||||
description: description,
|
||||
paid_at: paid_at,
|
||||
invoice_pdf_url: @stripe_invoice.invoice_pdf,
|
||||
receipt_url: @stripe_invoice.hosted_invoice_url
|
||||
)
|
||||
payment.save!
|
||||
payment
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_club
|
||||
club_id = metadata_club_id(@stripe_invoice)
|
||||
return Club.find_by(id: club_id) if club_id.present?
|
||||
|
||||
sub_id = @stripe_invoice.subscription
|
||||
return unless sub_id.present?
|
||||
|
||||
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
|
||||
end
|
||||
|
||||
def metadata_club_id(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
meta["club_id"] || meta[:club_id]
|
||||
end
|
||||
|
||||
def plan_slug
|
||||
meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata
|
||||
meta["plan_slug"] || meta[:plan_slug]
|
||||
end
|
||||
|
||||
def description
|
||||
lines = @stripe_invoice.lines&.data
|
||||
return @stripe_invoice.description if lines.blank?
|
||||
|
||||
lines.first.description.presence || @stripe_invoice.description
|
||||
end
|
||||
|
||||
def payment_intent_id
|
||||
pi = @stripe_invoice.payment_intent
|
||||
pi.is_a?(String) ? pi : pi&.id
|
||||
end
|
||||
|
||||
def charge_id
|
||||
charge = @stripe_invoice.charge
|
||||
charge.is_a?(String) ? charge : charge&.id
|
||||
end
|
||||
|
||||
def paid_at
|
||||
ts = @stripe_invoice.status_transitions&.paid_at
|
||||
ts ? Time.zone.at(ts) : Time.current
|
||||
end
|
||||
|
||||
def map_status(stripe_status)
|
||||
case stripe_status
|
||||
when "paid" then "paid"
|
||||
when "uncollectible", "void" then "failed"
|
||||
else "failed"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
27
backend/app/services/billing/stripe/subscription_period.rb
Normal file
27
backend/app/services/billing/stripe/subscription_period.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
module SubscriptionPeriod
|
||||
module_function
|
||||
|
||||
def times(stripe_sub)
|
||||
item = stripe_sub.items&.data&.first
|
||||
start_at = read_value(stripe_sub, :current_period_start) || read_value(item, :current_period_start)
|
||||
end_at = read_value(stripe_sub, :current_period_end) || read_value(item, :current_period_end)
|
||||
[unix_time(start_at), unix_time(end_at)]
|
||||
end
|
||||
|
||||
def read_value(object, field)
|
||||
return nil if object.blank?
|
||||
return nil unless object.respond_to?(field)
|
||||
|
||||
object.public_send(field)
|
||||
end
|
||||
|
||||
def unix_time(value)
|
||||
return nil if value.blank?
|
||||
|
||||
Time.zone.at(value.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
36
backend/app/services/billing/stripe/sync_payments.rb
Normal file
36
backend/app/services/billing/stripe/sync_payments.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class SyncPayments
|
||||
def self.call(club:)
|
||||
new(club: club).call
|
||||
end
|
||||
|
||||
def initialize(club:)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
customer_id = @club.subscription&.stripe_customer_id
|
||||
return 0 if customer_id.blank?
|
||||
|
||||
count = 0
|
||||
starting_after = nil
|
||||
|
||||
loop do
|
||||
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
|
||||
list.data.each do |invoice|
|
||||
next unless invoice.status == "paid"
|
||||
|
||||
RecordPayment.call(stripe_invoice: invoice, club: @club)
|
||||
count += 1
|
||||
end
|
||||
break unless list.has_more
|
||||
|
||||
starting_after = list.data.last.id
|
||||
end
|
||||
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,8 @@ module Billing
|
||||
downgrade_to_free(@event.data.object)
|
||||
when "invoice.payment_failed"
|
||||
mark_past_due(@event.data.object)
|
||||
when "invoice.paid"
|
||||
record_payment(@event.data.object)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,8 +51,7 @@ module Billing
|
||||
plan_slug = "premium_full" if plan_slug == "premium"
|
||||
plan = Plan[plan_slug]
|
||||
status = map_status(stripe_sub.status)
|
||||
period_start = unix_time(stripe_sub.current_period_start)
|
||||
period_end = unix_time(stripe_sub.current_period_end)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
@@ -84,6 +85,10 @@ module Billing
|
||||
)
|
||||
end
|
||||
|
||||
def record_payment(invoice)
|
||||
Billing::Stripe::RecordPayment.call(stripe_invoice: invoice)
|
||||
end
|
||||
|
||||
def mark_past_due(invoice)
|
||||
return if invoice.subscription.blank?
|
||||
|
||||
|
||||
110
backend/app/services/scoring/apply_action.rb
Normal file
110
backend/app/services/scoring/apply_action.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
module Scoring
|
||||
class ApplyAction
|
||||
Result = Struct.new(:score, :set_won, :match_won, :winner, keyword_init: true)
|
||||
|
||||
def initialize(session:, action:)
|
||||
@session = session
|
||||
@match = session.match
|
||||
@action = action.to_s
|
||||
@rules = Rules.from_match(@match)
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
case @action
|
||||
when "home_point"
|
||||
apply_point(score, :home)
|
||||
when "away_point"
|
||||
apply_point(score, :away)
|
||||
when "home_undo"
|
||||
apply_undo(score, :home)
|
||||
when "away_undo"
|
||||
apply_undo(score, :away)
|
||||
when "close_set"
|
||||
apply_close_set(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_point(score, side)
|
||||
if side == :home
|
||||
score.home_points += 1
|
||||
else
|
||||
score.away_points += 1
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
|
||||
end
|
||||
|
||||
def apply_undo(score, side)
|
||||
if side == :home
|
||||
score.home_points = [score.home_points - 1, 0].max
|
||||
else
|
||||
score.away_points = [score.away_points - 1, 0].max
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def apply_close_set(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
|
||||
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
|
||||
|
||||
close_set_for(score, winner)
|
||||
end
|
||||
|
||||
def close_set_for(score, winner)
|
||||
partials = Array(score.set_partials)
|
||||
partials << {
|
||||
"set" => score.current_set,
|
||||
"home" => score.home_points,
|
||||
"away" => score.away_points
|
||||
}
|
||||
|
||||
if winner == :home
|
||||
score.home_sets += 1
|
||||
else
|
||||
score.away_sets += 1
|
||||
end
|
||||
|
||||
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
|
||||
|
||||
score.assign_attributes(
|
||||
set_partials: partials,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: score.current_set + 1
|
||||
)
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(
|
||||
score: score,
|
||||
set_won: true,
|
||||
match_won: match_winner.present?,
|
||||
winner: match_winner || winner
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast(score)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
46
backend/app/services/scoring/rules.rb
Normal file
46
backend/app/services/scoring/rules.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
module Scoring
|
||||
class Rules
|
||||
Side = Struct.new(:home, :away, keyword_init: true)
|
||||
|
||||
def self.from_match(match)
|
||||
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
|
||||
new(
|
||||
sets_to_win: match.sets_to_win,
|
||||
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
|
||||
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
|
||||
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
|
||||
@sets_to_win = sets_to_win
|
||||
@points_per_set = points_per_set
|
||||
@points_deciding_set = points_deciding_set
|
||||
@min_point_lead = min_point_lead
|
||||
end
|
||||
|
||||
def max_sets
|
||||
(@sets_to_win * 2) - 1
|
||||
end
|
||||
|
||||
def points_target(current_set)
|
||||
current_set >= max_sets ? @points_deciding_set : @points_per_set
|
||||
end
|
||||
|
||||
def set_winner_from_points(home_points:, away_points:, current_set:)
|
||||
target = points_target(current_set)
|
||||
if home_points >= target && (home_points - away_points) >= @min_point_lead
|
||||
:home
|
||||
elsif away_points >= target && (away_points - home_points) >= @min_point_lead
|
||||
:away
|
||||
end
|
||||
end
|
||||
|
||||
def match_winner(home_sets:, away_sets:)
|
||||
return :home if home_sets >= @sets_to_win
|
||||
return :away if away_sets >= @sets_to_win
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
65
backend/app/services/sessions/regia_access.rb
Normal file
65
backend/app/services/sessions/regia_access.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
module Sessions
|
||||
class RegiaAccess
|
||||
DEFAULT_TTL = 12.hours
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
# Genera un nuovo link (invalida quello precedente).
|
||||
def issue_token!
|
||||
rotate!
|
||||
end
|
||||
|
||||
def rotate!
|
||||
token = SecureRandom.urlsafe_base64(24)
|
||||
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min
|
||||
@session.update!(
|
||||
regia_token_digest: digest(token),
|
||||
regia_token_expires_at: expires
|
||||
)
|
||||
token
|
||||
end
|
||||
|
||||
def url(token)
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/regia/#{token}"
|
||||
end
|
||||
|
||||
def self.find_session_by_token(token)
|
||||
return if token.blank?
|
||||
|
||||
session = StreamSession.find_by(regia_token_digest: digest(token))
|
||||
return unless session
|
||||
return if session.regia_token_expires_at.blank? || session.regia_token_expires_at.past?
|
||||
return if session.terminal?
|
||||
|
||||
session
|
||||
end
|
||||
|
||||
def self.revoke!(session)
|
||||
session.update!(regia_token_digest: nil, regia_token_expires_at: nil)
|
||||
end
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_valid?
|
||||
@session.regia_token_digest.present? &&
|
||||
@session.regia_token_expires_at&.future? &&
|
||||
!@session.terminal?
|
||||
end
|
||||
|
||||
def session_end_cap
|
||||
return nil unless @session.started_at
|
||||
|
||||
@session.started_at + 8.hours
|
||||
end
|
||||
|
||||
def digest(token)
|
||||
self.class.digest(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ module Sessions
|
||||
|
||||
def call
|
||||
cancel_timeout_job
|
||||
Sessions::RegiaAccess.revoke!(@session)
|
||||
@session.end_stream!
|
||||
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
|
||||
Recordings::IndexSession.new(@session).call
|
||||
|
||||
@@ -48,11 +48,15 @@ module Teams
|
||||
end
|
||||
|
||||
def staff_count
|
||||
staff_count_for("transmission") + staff_count_for("regia")
|
||||
staff_count_for("transmission")
|
||||
end
|
||||
|
||||
def staff_count_for(kind)
|
||||
members_count_for(kind) + pending_invitations_count_for(kind)
|
||||
def staff_count_for(_kind = "transmission")
|
||||
staff_coverage.covered_count
|
||||
end
|
||||
|
||||
def staff_assigned_for(_kind = "transmission")
|
||||
staff_coverage.assigned_count
|
||||
end
|
||||
|
||||
def members_count
|
||||
@@ -149,29 +153,19 @@ module Teams
|
||||
def assert_can_invite!(staff_kind: "transmission")
|
||||
return unless active?
|
||||
|
||||
kind = staff_kind.to_s
|
||||
unless TeamInvitation::STAFF_KINDS.include?(kind)
|
||||
raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind")
|
||||
end
|
||||
|
||||
limit = staff_limit_for(kind)
|
||||
limit = max_staff_transmission
|
||||
return if limit.nil?
|
||||
|
||||
used = staff_count_for(kind)
|
||||
used = staff_assigned_for
|
||||
return if used < limit
|
||||
|
||||
label = kind == "regia" ? "regia" : "trasmissione"
|
||||
raise EntitlementError.new(
|
||||
"Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.",
|
||||
"Limite responsabili trasmissione raggiunto (#{limit} all'anno). Passa a un piano superiore.",
|
||||
code: "staff_limit_reached",
|
||||
billing_url: billing_url
|
||||
)
|
||||
end
|
||||
|
||||
def staff_limit_for(kind)
|
||||
kind == "regia" ? max_staff_regia : max_staff_transmission
|
||||
end
|
||||
|
||||
def assert_can_connect_youtube!
|
||||
unless premium_full?
|
||||
raise EntitlementError.new(
|
||||
@@ -216,10 +210,8 @@ module Teams
|
||||
staff_manage_url: staff_manage_url,
|
||||
max_staff: max_staff,
|
||||
max_staff_transmission: max_staff_transmission,
|
||||
max_staff_regia: max_staff_regia,
|
||||
staff_used: staff_count,
|
||||
staff_transmission_used: staff_count_for("transmission"),
|
||||
staff_regia_used: staff_count_for("regia"),
|
||||
concurrent_streams_used: concurrent_streams_used,
|
||||
concurrent_streams_limit: concurrent_streams_limit,
|
||||
recordings_enabled: can_access_recordings?,
|
||||
@@ -230,6 +222,10 @@ module Teams
|
||||
}
|
||||
end
|
||||
|
||||
def staff_coverage
|
||||
@staff_coverage ||= StaffCoverage.new(@team)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_free_subscription!
|
||||
|
||||
@@ -9,77 +9,55 @@ module Teams
|
||||
end
|
||||
|
||||
class StaffAssignment
|
||||
def self.call(team:, user:, staff_kind:, membership: nil)
|
||||
def self.call(team:, user:, staff_kind: "transmission", membership: nil)
|
||||
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
|
||||
end
|
||||
|
||||
def initialize(team:, user:, staff_kind:, membership: nil)
|
||||
def initialize(team:, user:, staff_kind: "transmission", membership: nil)
|
||||
@team = team
|
||||
@user = user
|
||||
@staff_kind = staff_kind.to_s
|
||||
@staff_kind = "transmission"
|
||||
@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)
|
||||
StaffEmailValidator.assert_available!(
|
||||
team: @team, email: @user.email, except_user: @user
|
||||
)
|
||||
|
||||
ut = @membership || @user.user_teams.find_by!(team: @team)
|
||||
return ut if ut.staff_kind == @staff_kind
|
||||
return ut if ut.staff_kind == "transmission"
|
||||
|
||||
ent = Entitlements.new(@team)
|
||||
ent.assert_can_invite!(staff_kind: @staff_kind)
|
||||
ut.update!(staff_kind: @staff_kind)
|
||||
Entitlements.new(@team).assert_can_invite!
|
||||
ut.update!(staff_kind: "transmission")
|
||||
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!
|
||||
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
|
||||
new(team: team, email: email, except_user: except_user).assert_available!
|
||||
end
|
||||
|
||||
def initialize(team:, email:, staff_kind:, except_user: nil)
|
||||
def initialize(team:, email:, 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
|
||||
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
|
||||
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
|
||||
if user_scope.exists?
|
||||
raise StaffAssignmentError,
|
||||
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
|
||||
end
|
||||
|
||||
conflict = conflicting_assignment
|
||||
return unless conflict
|
||||
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
|
||||
return unless inv.exists?
|
||||
|
||||
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
|
||||
"Esiste già un invito in sospeso per #{@email}."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
35
backend/app/services/teams/staff_coverage.rb
Normal file
35
backend/app/services/teams/staff_coverage.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
module Teams
|
||||
class StaffCoverage
|
||||
def initialize(team)
|
||||
@team = team
|
||||
end
|
||||
|
||||
def assigned_count(_kind = "transmission")
|
||||
members_count + pending_invitations_count
|
||||
end
|
||||
|
||||
def covered_count(_kind = "transmission")
|
||||
assigned_count.positive? ? 1 : 0
|
||||
end
|
||||
|
||||
def role_label_for(membership)
|
||||
return "Trasmissione" if membership.staff_kind.present?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def open_slot_for_invite?(_kind = "transmission")
|
||||
assigned_count < 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def members_count
|
||||
@team.user_teams.where.not(staff_kind: nil).count
|
||||
end
|
||||
|
||||
def pending_invitations_count
|
||||
@team.team_invitations.pending.count
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user