From db8b14ba4ed935e7dccb4cde9fb265c1d5edb13a Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 15 Aug 2026 20:02:25 +0200 Subject: [PATCH] Snellisci l'invito a trasmettere e invia l'email automatica. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il form invito è sulla pagina squadra, genera il link e manda la mail; in caso di errore SMTP resta la copia manuale. Co-authored-by: Cursor --- .../controllers/public/teams_controller.rb | 26 +++++++-- .../app/mailers/teams/invitation_mailer.rb | 25 ++++++++ .../app/services/teams/staff_assignment.rb | 36 ------------ .../services/teams/staff_assignment_error.rb | 10 ++++ .../services/teams/staff_email_validator.rb | 28 +++++++++ backend/app/views/layouts/mailer.html.erb | 2 +- backend/app/views/layouts/marketing.html.erb | 2 +- .../app/views/layouts/marketing_live.html.erb | 2 +- .../public/teams/_streaming_staff.html.erb | 41 ++++++++++++- .../app/views/public/teams/details.html.erb | 2 +- .../app/views/public/teams/invite.html.erb | 36 +----------- .../transmission_invite.html.erb | 31 ++++++++++ .../transmission_invite.text.erb | 19 +++++++ backend/config/locales/app.de.yml | 10 +++- backend/config/locales/app.en.yml | 10 +++- backend/config/locales/app.es.yml | 10 +++- backend/config/locales/app.fr.yml | 10 +++- backend/config/locales/app.it.yml | 10 +++- backend/config/locales/mailers.de.yml | 13 +++++ backend/config/locales/mailers.en.yml | 13 +++++ backend/config/locales/mailers.es.yml | 13 +++++ backend/config/locales/mailers.fr.yml | 13 +++++ backend/config/locales/mailers.it.yml | 13 +++++ backend/public/marketing.css | 46 +++++++++++++++ .../teams/invitation_mailer_preview.rb | 25 ++++++++ .../mailers/teams/invitation_mailer_spec.rb | 30 ++++++++++ .../spec/requests/api/v1/invitations_spec.rb | 8 +-- .../spec/requests/public/team_invite_spec.rb | 57 +++++++++++++++++++ .../services/teams/staff_assignment_spec.rb | 4 +- 29 files changed, 448 insertions(+), 97 deletions(-) create mode 100644 backend/app/mailers/teams/invitation_mailer.rb create mode 100644 backend/app/services/teams/staff_assignment_error.rb create mode 100644 backend/app/services/teams/staff_email_validator.rb create mode 100644 backend/app/views/teams/invitation_mailer/transmission_invite.html.erb create mode 100644 backend/app/views/teams/invitation_mailer/transmission_invite.text.erb create mode 100644 backend/spec/mailers/previews/teams/invitation_mailer_preview.rb create mode 100644 backend/spec/mailers/teams/invitation_mailer_spec.rb create mode 100644 backend/spec/requests/public/team_invite_spec.rb diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index c9b3a5f..84e6374 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -58,7 +58,7 @@ module Public def invite require_club_owner_for_team!(@team) - @entitlements = @team.entitlements + redirect_to public_team_details_path(@team, anchor: "invita-trasmissione") end def assign_self_staff @@ -87,18 +87,32 @@ module Public 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!( + invitation = @team.team_invitations.create!( email: email, token_digest: Digest::SHA256.hexdigest(token), role: "member", staff_kind: staff_kind, expires_at: 7.days.from_now ) - @invite_url = join_public_invitation_url(token: token) - flash.now[:notice] = t("flash.teams.invite_link_generated") - render :invite + invite_url = public_invitation_url(token: token) + flash[:invite_url] = invite_url + + begin + Teams::InvitationMailer.transmission_invite( + team: @team, + invitation: invitation, + invite_url: invite_url, + invited_by: current_user + ).deliver_now + flash[:notice] = t("flash.teams.invite_email_sent", email: email) + rescue StandardError => e + Rails.logger.error("[invite_email] #{e.class}: #{e.message}") + flash[:alert] = t("flash.teams.invite_email_failed", email: email) + end + + redirect_to public_team_details_path(@team, anchor: "invito-generato") rescue Teams::EntitlementError, Teams::StaffAssignmentError => e - redirect_to public_team_invite_path(@team), alert: e.message + redirect_to public_team_details_path(@team, anchor: "invita-trasmissione"), alert: e.message end def remove_member diff --git a/backend/app/mailers/teams/invitation_mailer.rb b/backend/app/mailers/teams/invitation_mailer.rb new file mode 100644 index 0000000..bed0a1e --- /dev/null +++ b/backend/app/mailers/teams/invitation_mailer.rb @@ -0,0 +1,25 @@ +module Teams + class InvitationMailer < ApplicationMailer + default from: -> { MatchLiveTv.mail_from } + + def transmission_invite(team:, invitation:, invite_url:, invited_by:) + @team = team + @club = team.club + @invitation = invitation + @invite_url = invite_url + @invited_by = invited_by + @expires_on = I18n.l(invitation.expires_at.to_date, format: :long) + + I18n.with_locale(I18n.locale) do + mail( + to: invitation.email, + subject: t( + "mailers.transmission_invite.subject", + team: @team.name, + club: @club.name + ) + ) + end + end + end +end diff --git a/backend/app/services/teams/staff_assignment.rb b/backend/app/services/teams/staff_assignment.rb index 940190a..14ecc2e 100644 --- a/backend/app/services/teams/staff_assignment.rb +++ b/backend/app/services/teams/staff_assignment.rb @@ -1,13 +1,4 @@ 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: "transmission", membership: nil) new(team: team, user: user, staff_kind: staff_kind, membership: membership).call @@ -33,31 +24,4 @@ module Teams ut end end - - class StaffEmailValidator - 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:, except_user: nil) - @team = team - @email = email.to_s.downcase.strip - @except_user = except_user - end - - def assert_available! - 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 - - inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email) - return unless inv.exists? - - raise StaffAssignmentError, - "Esiste già un invito in sospeso per #{@email}." - end - end end diff --git a/backend/app/services/teams/staff_assignment_error.rb b/backend/app/services/teams/staff_assignment_error.rb new file mode 100644 index 0000000..15fda89 --- /dev/null +++ b/backend/app/services/teams/staff_assignment_error.rb @@ -0,0 +1,10 @@ +module Teams + class StaffAssignmentError < StandardError + attr_reader :message + + def initialize(message) + @message = message + super(message) + end + end +end diff --git a/backend/app/services/teams/staff_email_validator.rb b/backend/app/services/teams/staff_email_validator.rb new file mode 100644 index 0000000..957f230 --- /dev/null +++ b/backend/app/services/teams/staff_email_validator.rb @@ -0,0 +1,28 @@ +module Teams + class StaffEmailValidator + 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:, except_user: nil) + @team = team + @email = email.to_s.downcase.strip + @except_user = except_user + end + + def assert_available! + 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 + + inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email) + return unless inv.exists? + + raise StaffAssignmentError, + "Esiste già un invito in sospeso per #{@email}." + end + end +end diff --git a/backend/app/views/layouts/mailer.html.erb b/backend/app/views/layouts/mailer.html.erb index 3aac900..1f4688e 100644 --- a/backend/app/views/layouts/mailer.html.erb +++ b/backend/app/views/layouts/mailer.html.erb @@ -7,7 +7,7 @@ - + <%= yield %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index c1c353a..ff5b72a 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 7294dac..e4cb00c 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/public/teams/_streaming_staff.html.erb b/backend/app/views/public/teams/_streaming_staff.html.erb index 58b3430..e367be4 100644 --- a/backend/app/views/public/teams/_streaming_staff.html.erb +++ b/backend/app/views/public/teams/_streaming_staff.html.erb @@ -6,7 +6,7 @@ <% recordings = local_assigns[:recordings] %> <% entitlements = local_assigns[:entitlements] %> -
+
<% if can_manage %> <% if owner_membership&.staff_kind.blank? %>
@@ -51,12 +51,34 @@ <% else %>

<%= t("team.streaming_staff.no_staff") %> - <% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), public_team_invite_path(team) %>.<% end %> + <% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), "#invita-trasmissione" %>.<% end %>

<% end %>
<% if can_manage %> +
+

<%= t("team.streaming_staff.invite_form_heading") %>

+

<%= t("team.streaming_staff.invite_form_hint") %>

+ <%= form_with url: public_team_invite_path(team), method: :post, class: "team-invite-form__fields" do %> + <%= hidden_field_tag :staff_kind, "transmission" %> + <%= label_tag :email, t("team.invite.email_label") %> + <%= email_field_tag :email, params[:email], required: true, autocomplete: "email", placeholder: "nome@email.it" %> + <%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %> + <% end %> + + <% if flash[:invite_url].present? %> + + <% end %> +
+

<%= t("team.streaming_staff.pending_invitations_heading") %>

<% if pending_invitations.any? %> @@ -114,3 +136,18 @@
<% end %>
+ + diff --git a/backend/app/views/public/teams/details.html.erb b/backend/app/views/public/teams/details.html.erb index 79d1e7e..cae8083 100644 --- a/backend/app/views/public/teams/details.html.erb +++ b/backend/app/views/public/teams/details.html.erb @@ -56,7 +56,7 @@ <% if @can_manage %> <%= link_to t("club.back_to_club"), public_club_path(@club), class: "btn btn-secondary" %> <%= link_to t("team.details.public_page"), public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %> - <%= link_to t("team.details.streaming_managers"), public_team_invite_path(@team), class: "btn btn-secondary" %> + <%= link_to t("team.details.streaming_managers"), "#responsabili-trasmissione", class: "btn btn-secondary" %> <% end %> <% if current_user.can_stream_for?(@team) %> <%= link_to t("team.details.schedule_matches"), public_team_matches_path(@team), class: "btn btn-primary" %> diff --git a/backend/app/views/public/teams/invite.html.erb b/backend/app/views/public/teams/invite.html.erb index 655e589..68caf38 100644 --- a/backend/app/views/public/teams/invite.html.erb +++ b/backend/app/views/public/teams/invite.html.erb @@ -1,38 +1,8 @@ +<%# Pagina legacy: l'invito è sulla pagina dettagli squadra. %> <% content_for :title, t("team.invite.title", name: @team.name) %> <% content_for :robots, "noindex, nofollow" %>
-

<%= t("team.invite.heading") %>

-

- <%= t("team.invite.assigned_label") %> <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> - <%= t("team.invite.per_team_note") %> -

-

- <%= raw t("team.invite.info") %> -

- - <% owner_membership = current_user.user_teams.find_by(team: @team) %> - <% if owner_membership&.staff_kind.blank? %> -
-

- <%= raw t("team.invite.self_assign_hint_html", email: current_user.email) %> -

- <%= button_to t("team.invite.self_assign_submit"), public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %> -
- <% end %> - -
-

<%= t("team.invite.invite_other_heading") %>

- <%= form_with url: public_team_invite_path(@team), method: :post do %> - <%= hidden_field_tag :staff_kind, "transmission" %> - <%= label_tag :email, t("team.invite.email_label") %> - <%= email_field_tag :email, params[:email], required: true %> - <%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %> - <% end %> - <% if defined?(@invite_url) && @invite_url.present? %> -

<%= t("team.invite.share_hint") %>

- <%= @invite_url %> - <% end %> -
-

<%= link_to t("team.back_to_details"), public_team_details_path(@team) %>

+

<%= t("team.streaming_staff.invite_form_hint") %>

+

<%= link_to t("team.back_to_details"), public_team_details_path(@team, anchor: "invita-trasmissione"), class: "btn btn-primary" %>

diff --git a/backend/app/views/teams/invitation_mailer/transmission_invite.html.erb b/backend/app/views/teams/invitation_mailer/transmission_invite.html.erb new file mode 100644 index 0000000..0e27ce0 --- /dev/null +++ b/backend/app/views/teams/invitation_mailer/transmission_invite.html.erb @@ -0,0 +1,31 @@ +
+

<%= t("mailers.transmission_invite.hello") %>

+ +

+ <%= raw t( + "mailers.transmission_invite.body_html", + inviter: @invited_by.name, + team: @team.name, + club: @club.name + ) %> +

+ +

+ + <%= t("mailers.transmission_invite.cta") %> + +

+ +

+ <%= t("mailers.transmission_invite.link_fallback") %>
+ <%= @invite_url %> +

+ +

<%= t("mailers.transmission_invite.steps") %>

+ +

<%= t("mailers.transmission_invite.expiry", date: @expires_on) %>

+ +

<%= t("mailers.transmission_invite.ignore") %>

+ +

<%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %>

+
diff --git a/backend/app/views/teams/invitation_mailer/transmission_invite.text.erb b/backend/app/views/teams/invitation_mailer/transmission_invite.text.erb new file mode 100644 index 0000000..0d25472 --- /dev/null +++ b/backend/app/views/teams/invitation_mailer/transmission_invite.text.erb @@ -0,0 +1,19 @@ +<%= t("mailers.transmission_invite.hello") %> + +<%= t( + "mailers.transmission_invite.body_text", + inviter: @invited_by.name, + team: @team.name, + club: @club.name +) %> + +<%= t("mailers.transmission_invite.link_text") %> +<%= @invite_url %> + +<%= t("mailers.transmission_invite.steps") %> + +<%= t("mailers.transmission_invite.expiry", date: @expires_on) %> + +<%= t("mailers.transmission_invite.ignore") %> + +<%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %> diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 06908ed..5eef396 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -170,8 +170,8 @@ de: self_assign_submit: Das mache ich — Übertragungsverantwortlicher invite_other_heading: Eine weitere E-Mail einladen email_label: E-Mail des Übertragungsverantwortlichen - submit: Zugangslink erstellen - share_hint: "Teilen (7 Tage gültig):" + submit: Zugangslink per E-Mail senden + share_hint: "Link auch per E-Mail gesendet (7 Tage gültig):" billing: title: "Abonnement — %{name}" team_label: "Team:" @@ -208,6 +208,10 @@ de: revoke_confirm: "Zugriff von %{name} widerrufen?" no_staff: Noch keine Übertragungsverantwortlichen. invite_someone: Jemanden einladen + invite_form_heading: Account zum Übertragen einladen + invite_form_hint: "Das ist nicht der Kader (Spieler/Staff vor Ort). E-Mail eingeben: wir senden den Link automatisch; du kannst ihn auch kopieren und teilen." + invite_once_note: "Bei Bedarf den Link unten auch für einen zweiten Versand kopieren (WhatsApp, SMS)." + copied: Kopiert pending_invitations_heading: Ausstehende Einladungen col_email_short: E-Mail col_expires: Läuft ab @@ -594,6 +598,8 @@ de: now_staff: Dein Konto ist jetzt Übertragungsverantwortlicher. staff_role_removed: Staff-Rolle von deinem Konto entfernt. invite_link_generated: "Einladungslink erstellt (7 Tage gültig)" + invite_email_sent: "Einladung an %{email} gesendet (Link 7 Tage gültig)" + invite_email_failed: "Einladung erstellt, aber die E-Mail an %{email} konnte nicht gesendet werden. Kopiere und teile den Link unten." access_revoked: Zugriff widerrufen invitation_canceled: Einladung storniert youtube_disconnected: YouTube-Kanal des Vereins getrennt diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index d0f48a0..84e0d1a 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -165,8 +165,8 @@ en: self_assign_submit: I'll do it — broadcast manager invite_other_heading: Invite another email email_label: Broadcast manager's email - submit: Generate access link - share_hint: "Share (valid for 7 days):" + submit: Send invite by email + share_hint: "Link also sent by email (valid for 7 days):" billing: title: "Subscription — %{name}" team_label: "Team:" @@ -203,6 +203,10 @@ en: revoke_confirm: "Revoke access for %{name}?" no_staff: No broadcast managers yet. invite_someone: Invite someone + invite_form_heading: Invite an account to broadcast + invite_form_hint: "This is not the roster (players/on-court staff). Enter the email: we send the link automatically; you can also copy and share it." + invite_once_note: "If needed, copy the link below for a second share (WhatsApp, SMS)." + copied: Copied pending_invitations_heading: Pending invitations col_email_short: Email col_expires: Expires @@ -589,6 +593,8 @@ en: now_staff: Your account is now a broadcast manager. staff_role_removed: Staff role removed from your account. invite_link_generated: "Invite link generated (valid for 7 days)" + invite_email_sent: "Invite sent to %{email} (link valid for 7 days)" + invite_email_failed: "Invite created, but the email to %{email} could not be sent. Copy and share the link below." access_revoked: Access revoked invitation_canceled: Invitation canceled youtube_disconnected: Club YouTube channel disconnected diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index 07ed135..84daa0b 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -170,8 +170,8 @@ es: self_assign_submit: Yo me encargo — responsable de transmisión invite_other_heading: Invitar a otro correo email_label: Correo del responsable de transmisión - submit: Generar enlace de acceso - share_hint: "Comparte (válido 7 días):" + submit: Enviar invitación por email + share_hint: "Enlace también enviado por email (válido 7 días):" billing: title: "Suscripción — %{name}" team_label: "Equipo:" @@ -208,6 +208,10 @@ es: revoke_confirm: "¿Revocar el acceso a %{name}?" no_staff: Aún no hay responsables de transmisión. invite_someone: Invitar a alguien + invite_form_heading: Invitar una cuenta a transmitir + invite_form_hint: "No es la plantilla (jugadores/staff en pista). Introduce el email: enviamos el enlace automáticamente; también puedes copiarlo y compartirlo." + invite_once_note: "Si hace falta, copia también el enlace abajo para un segundo envío (WhatsApp, SMS)." + copied: Copiado pending_invitations_heading: Invitaciones pendientes col_email_short: Correo col_expires: Caduca @@ -594,6 +598,8 @@ es: now_staff: Tu cuenta ahora es responsable de transmisión. staff_role_removed: Rol de staff eliminado de tu cuenta. invite_link_generated: "Enlace de invitación generado (válido 7 días)" + invite_email_sent: "Invitación enviada a %{email} (enlace válido 7 días)" + invite_email_failed: "Invitación creada, pero no se pudo enviar el email a %{email}. Copia y comparte el enlace de abajo." access_revoked: Acceso revocado invitation_canceled: Invitación anulada youtube_disconnected: Canal de YouTube del club desconectado diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index a4a90e2..84af97c 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -170,8 +170,8 @@ fr: self_assign_submit: C'est moi — responsable diffusion invite_other_heading: Inviter un autre e-mail email_label: E-mail du responsable diffusion - submit: Générer le lien d'accès - share_hint: "Partage (valable 7 jours) :" + submit: Envoyer l'invitation par e-mail + share_hint: "Lien aussi envoyé par e-mail (valable 7 jours) :" billing: title: "Abonnement — %{name}" team_label: "Équipe :" @@ -208,6 +208,10 @@ fr: revoke_confirm: "Révoquer l'accès de %{name} ?" no_staff: Aucun responsable diffusion pour le moment. invite_someone: Inviter quelqu'un + invite_form_heading: Inviter un compte à diffuser + invite_form_hint: "Ce n’est pas l’effectif (joueurs/staff sur le terrain). Saisissez l’e-mail : nous envoyons le lien automatiquement ; vous pouvez aussi le copier et le partager." + invite_once_note: "Si besoin, copiez aussi le lien ci-dessous pour un second envoi (WhatsApp, SMS)." + copied: Copié pending_invitations_heading: Invitations en attente col_email_short: E-mail col_expires: Expire @@ -594,6 +598,8 @@ fr: now_staff: Ton compte est maintenant responsable diffusion. staff_role_removed: Rôle staff retiré de ton compte. invite_link_generated: "Lien d'invitation généré (valable 7 jours)" + invite_email_sent: "Invitation envoyée à %{email} (lien valable 7 jours)" + invite_email_failed: "Invitation créée, mais l'e-mail à %{email} n'a pas pu être envoyé. Copiez et partagez le lien ci-dessous." access_revoked: Accès révoqué invitation_canceled: Invitation annulée youtube_disconnected: Chaîne YouTube du club déconnectée diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index d8e9f5d..4e9fd5e 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -165,8 +165,8 @@ it: self_assign_submit: Sono io — responsabile trasmissione invite_other_heading: Invita un'altra email email_label: Email del responsabile trasmissione - submit: Genera link di accesso - share_hint: "Condividi (valido 7 giorni):" + submit: Invia invito per email + share_hint: "Link inviato anche per email (valido 7 giorni):" billing: title: "Abbonamento — %{name}" team_label: "Squadra:" @@ -203,6 +203,10 @@ it: revoke_confirm: "Revocare l'accesso a %{name}?" no_staff: Nessun responsabile trasmissione. invite_someone: Invita qualcuno + invite_form_heading: Invita un account a trasmettere + invite_form_hint: "Non è l’organico (giocatori/staff in campo). Inserisci l’email: inviamo il link automaticamente; puoi anche copiarlo e condividerlo." + invite_once_note: "Se serve, copia il link anche qui sotto per un secondo invio (WhatsApp, SMS)." + copied: Copiato pending_invitations_heading: Inviti in attesa col_email_short: Email col_expires: Scade @@ -589,6 +593,8 @@ it: now_staff: Il tuo account è ora responsabile trasmissione. staff_role_removed: Ruolo staff rimosso dal tuo account. invite_link_generated: "Link invito generato (valido 7 giorni)" + invite_email_sent: "Invito inviato a %{email} (link valido 7 giorni)" + invite_email_failed: "Invito creato, ma l’email a %{email} non è partita. Copia e condividi il link qui sotto." access_revoked: Accesso revocato invitation_canceled: Invito annullato youtube_disconnected: Canale YouTube della società scollegato diff --git a/backend/config/locales/mailers.de.yml b/backend/config/locales/mailers.de.yml index 6401652..b2a3cee 100644 --- a/backend/config/locales/mailers.de.yml +++ b/backend/config/locales/mailers.de.yml @@ -31,6 +31,19 @@ de: open_text: "Öffnen: %{url}" archive_text: "Archiv: %{url}" footer: "Match Live TV — %{email}" + transmission_invite: + subject: "Einladung zum Übertragen — %{team} (%{club})" + hello: Hallo, + body_html: "%{inviter} hat dich als Übertragungsverantwortliche/n für %{team} (%{club}) auf Match Live TV eingeladen." + body_text: "%{inviter} hat dich als Übertragungsverantwortliche/n für %{team} (%{club}) auf Match Live TV eingeladen." + cta: Einladung annehmen + link_html: Einladung annehmen + link_text: "Öffne diesen Link zum Annehmen:" + link_fallback: "Falls der Button nicht funktioniert, öffne diesen Link:" + steps: "Melde dich mit derselben E-Mail an oder registriere dich, nimm die Einladung an und starte Übertragungen in der Match-Live-TV-App." + expiry: "Der Link ist gültig bis %{date}." + ignore: Wenn du diese Nachricht nicht erwartet hast, kannst du sie ignorieren. + footer: "Match Live TV — %{email}" invoice: subject: "Rechnung %{number} — Match Live TV" attachment_prefix: rechnung diff --git a/backend/config/locales/mailers.en.yml b/backend/config/locales/mailers.en.yml index ebe6374..de38023 100644 --- a/backend/config/locales/mailers.en.yml +++ b/backend/config/locales/mailers.en.yml @@ -31,6 +31,19 @@ en: open_text: "Open: %{url}" archive_text: "Archive: %{url}" footer: "Match Live TV — %{email}" + transmission_invite: + subject: "Broadcast invite — %{team} (%{club})" + hello: Hi, + body_html: "%{inviter} invited you as a broadcast manager for %{team} (%{club}) on Match Live TV." + body_text: "%{inviter} invited you as a broadcast manager for %{team} (%{club}) on Match Live TV." + cta: Accept the invite + link_html: Accept the invite + link_text: "Open this link to accept:" + link_fallback: "If the button does not work, open this link:" + steps: "Sign in or register with this same email, accept the invite, then start streams from the Match Live TV app." + expiry: "The link is valid until %{date}." + ignore: If you were not expecting this message, you can ignore it. + footer: "Match Live TV — %{email}" invoice: subject: "Invoice %{number} — Match Live TV" attachment_prefix: invoice diff --git a/backend/config/locales/mailers.es.yml b/backend/config/locales/mailers.es.yml index 2f5d2f1..6e78f88 100644 --- a/backend/config/locales/mailers.es.yml +++ b/backend/config/locales/mailers.es.yml @@ -31,6 +31,19 @@ es: open_text: "Abrir: %{url}" archive_text: "Archivo: %{url}" footer: "Match Live TV — %{email}" + transmission_invite: + subject: "Invitación a transmitir — %{team} (%{club})" + hello: Hola, + body_html: "%{inviter} te ha invitado como responsable de transmisión de %{team} (%{club}) en Match Live TV." + body_text: "%{inviter} te ha invitado como responsable de transmisión de %{team} (%{club}) en Match Live TV." + cta: Aceptar la invitación + link_html: Aceptar la invitación + link_text: "Abre este enlace para aceptar:" + link_fallback: "Si el botón no funciona, abre este enlace:" + steps: "Inicia sesión o regístrate con este mismo email, acepta la invitación y lanza las directos desde la app Match Live TV." + expiry: "El enlace es válido hasta el %{date}." + ignore: Si no esperabas este mensaje, puedes ignorarlo. + footer: "Match Live TV — %{email}" invoice: subject: "Factura %{number} — Match Live TV" attachment_prefix: factura diff --git a/backend/config/locales/mailers.fr.yml b/backend/config/locales/mailers.fr.yml index 308b581..a447619 100644 --- a/backend/config/locales/mailers.fr.yml +++ b/backend/config/locales/mailers.fr.yml @@ -31,6 +31,19 @@ fr: open_text: "Ouvrir : %{url}" archive_text: "Archive : %{url}" footer: "Match Live TV — %{email}" + transmission_invite: + subject: "Invitation à diffuser — %{team} (%{club})" + hello: Bonjour, + body_html: "%{inviter} vous a invité comme responsable diffusion pour %{team} (%{club}) sur Match Live TV." + body_text: "%{inviter} vous a invité comme responsable diffusion pour %{team} (%{club}) sur Match Live TV." + cta: Accepter l’invitation + link_html: Accepter l’invitation + link_text: "Ouvrez ce lien pour accepter :" + link_fallback: "Si le bouton ne fonctionne pas, ouvrez ce lien :" + steps: "Connectez-vous ou inscrivez-vous avec cette même adresse e-mail, acceptez l’invitation, puis lancez les directs depuis l’app Match Live TV." + expiry: "Le lien est valable jusqu’au %{date}." + ignore: Si vous n’attendiez pas ce message, vous pouvez l’ignorer. + footer: "Match Live TV — %{email}" invoice: subject: "Facture %{number} — Match Live TV" attachment_prefix: facture diff --git a/backend/config/locales/mailers.it.yml b/backend/config/locales/mailers.it.yml index 7aaa22c..29075ba 100644 --- a/backend/config/locales/mailers.it.yml +++ b/backend/config/locales/mailers.it.yml @@ -31,6 +31,19 @@ it: open_text: "Apri: %{url}" archive_text: "Archivio: %{url}" footer: "Match Live TV — %{email}" + transmission_invite: + subject: "Invito a trasmettere — %{team} (%{club})" + hello: Ciao, + body_html: "%{inviter} ti ha invitato come responsabile trasmissione per %{team} (%{club}) su Match Live TV." + body_text: "%{inviter} ti ha invitato come responsabile trasmissione per %{team} (%{club}) su Match Live TV." + cta: Accetta l’invito + link_html: Accetta l’invito + link_text: "Apri questo link per accettare:" + link_fallback: "Se il pulsante non funziona, apri questo link:" + steps: "Accedi o registrati con questa stessa email, accetta l’invito e avvia le dirette dall’app Match Live TV." + expiry: "Il link è valido fino al %{date}." + ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo. + footer: "Match Live TV — %{email}" invoice: subject: "Fattura %{number} — Match Live TV" attachment_prefix: fattura diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 5272755..7589945 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -875,10 +875,56 @@ body.nav-menu-open { overflow: hidden; } margin-top: 36px; padding-top: 28px; border-top: 1px solid #2a2a36; + scroll-margin-top: 88px; } .team-streaming-staff h2 { margin: 24px 0 12px; font-size: 1.1rem; } .team-streaming-staff__self { margin-bottom: 20px; } .team-streaming-staff__self h2 { margin-top: 0; font-size: 1.05rem; } +.team-invite-form { + margin: 24px 0; + max-width: 560px; + scroll-margin-top: 88px; +} +.team-invite-form h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} +.team-invite-form__hint { + margin: 0 0 14px; + max-width: 48rem; + line-height: 1.45; +} +.team-invite-form__fields label { + display: block; + margin-bottom: 6px; + font-size: 0.9rem; + color: #c8c8d0; +} +.team-invite-form__fields input[type="email"] { + width: 100%; + max-width: 360px; + margin-bottom: 12px; +} +.team-invite-link { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid #2a2a36; + scroll-margin-top: 88px; +} +.team-invite-link__title { + margin: 0 0 8px; + font-weight: 600; +} +.team-invite-link__url { + display: block; + word-break: break-all; + background: #0a0a0e; + padding: 12px; + border-radius: 8px; + margin-bottom: 10px; +} +.team-invite-link__copy { margin-bottom: 8px; } +.team-invite-link__note { margin: 0; font-size: 0.88rem; } .roster-layout--readonly { grid-template-columns: 1fr; } .roster-stats { display: flex; diff --git a/backend/spec/mailers/previews/teams/invitation_mailer_preview.rb b/backend/spec/mailers/previews/teams/invitation_mailer_preview.rb new file mode 100644 index 0000000..17a25e0 --- /dev/null +++ b/backend/spec/mailers/previews/teams/invitation_mailer_preview.rb @@ -0,0 +1,25 @@ +# Anteprima: http://localhost:3000/rails/mailers/teams/invitation_mailer/transmission_invite +module Teams + class InvitationMailerPreview < ActionMailer::Preview + def transmission_invite + team = Team.includes(:club).order(:created_at).first + raise "Nessuna squadra in DB: esegui seed" unless team + + invited_by = User.order(:created_at).first || User.new(name: "Coach Demo", email: "coach@matchlivetv.test") + invitation = TeamInvitation.new( + email: "demo.streamer@example.com", + expires_at: 7.days.from_now, + role: "member", + staff_kind: "transmission" + ) + invite_url = "http://localhost:3000/join/anteprima-token-esempio" + + ::Teams::InvitationMailer.transmission_invite( + team: team, + invitation: invitation, + invite_url: invite_url, + invited_by: invited_by + ) + end + end +end diff --git a/backend/spec/mailers/teams/invitation_mailer_spec.rb b/backend/spec/mailers/teams/invitation_mailer_spec.rb new file mode 100644 index 0000000..ae88fcf --- /dev/null +++ b/backend/spec/mailers/teams/invitation_mailer_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe Teams::InvitationMailer, type: :mailer do + let!(:club) { Club.create!(name: "Mail Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") } + let!(:inviter) { User.create!(name: "Coach", email: "coach-mail@test.it", password: "Password123", role: "coach") } + let(:invitation) do + TeamInvitation.new( + email: "streamer@test.it", + expires_at: 7.days.from_now, + role: "member", + staff_kind: "transmission" + ) + end + + it "invia l'invito con link e oggetto corretti" do + mail = described_class.transmission_invite( + team: team, + invitation: invitation, + invite_url: "http://www.example.com/join/token-demo", + invited_by: inviter + ) + + expect(mail.to).to eq(["streamer@test.it"]) + expect(mail.subject).to include("U16") + expect(mail.subject).to include("Mail Club") + expect(mail.body.encoded).to include("http://www.example.com/join/token-demo") + expect(mail.body.encoded).to include("Coach") + end +end diff --git a/backend/spec/requests/api/v1/invitations_spec.rb b/backend/spec/requests/api/v1/invitations_spec.rb index c06b93a..3eb928a 100644 --- a/backend/spec/requests/api/v1/invitations_spec.rb +++ b/backend/spec/requests/api/v1/invitations_spec.rb @@ -4,11 +4,11 @@ RSpec.describe "Api::V1::Invitations", type: :request do let(:club) { Club.create!(name: "FC Test", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:team) { club.teams.create!(name: "Squadra A", sport: "volleyball") } let(:owner) do - User.create!(email: "owner@test.it", name: "Owner", password: "password123", role: "coach").tap do |u| + User.create!(email: "owner@test.it", name: "Owner", password: "Password123", role: "coach").tap do |u| ClubMembership.create!(user: u, club: club, role: "owner") end end - let(:invitee) { User.create!(email: "streamer@test.it", name: "Streamer", password: "password123", role: "coach") } + let(:invitee) { User.create!(email: "streamer@test.it", name: "Streamer", password: "Password123", role: "coach") } let(:token) { TeamInvitation.generate_token } let!(:invitation) do team.team_invitations.create!( @@ -31,7 +31,7 @@ RSpec.describe "Api::V1::Invitations", type: :request do describe "POST /api/v1/invitations/:token/accept" do it "accetta con email corretta" do - post "/api/v1/auth/login", params: { email: invitee.email, password: "password123" } + post "/api/v1/auth/login", params: { email: invitee.email, password: "Password123" } access = response.parsed_body["access_token"] post "/api/v1/invitations/#{token}/accept", @@ -42,7 +42,7 @@ RSpec.describe "Api::V1::Invitations", type: :request do end it "rifiuta email diversa" do - post "/api/v1/auth/login", params: { email: owner.email, password: "password123" } + post "/api/v1/auth/login", params: { email: owner.email, password: "Password123" } access = response.parsed_body["access_token"] post "/api/v1/invitations/#{token}/accept", diff --git a/backend/spec/requests/public/team_invite_spec.rb b/backend/spec/requests/public/team_invite_spec.rb new file mode 100644 index 0000000..7d8c98b --- /dev/null +++ b/backend/spec/requests/public/team_invite_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe "Public team transmission invite", type: :request do + let!(:coach) do + User.create!(email: "invite-owner@test.it", name: "Owner", password: "Password123", role: "coach") + end + let!(:club) do + c = Club.create!(name: "Invite Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") + ClubMembership.create!(user: coach, club: c, role: "owner") + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: c, plan_slug: "premium_light") + c + end + let!(:team) { club.teams.create!(name: "U13 Black", sport: "volleyball") } + + def login! + post public_login_path, params: { email: coach.email, password: "Password123" } + end + + it "mostra il form invito sulla pagina dettagli squadra" do + login! + get public_team_details_path(team) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Invita un account a trasmettere") + expect(response.body).to include('id="invita-trasmissione"') + expect(response.body).to include("#responsabili-trasmissione") + end + + it "GET /teams/:id/invite reindirizza ai dettagli con ancora" do + login! + get public_team_invite_path(team) + + expect(response).to redirect_to(public_team_details_path(team, anchor: "invita-trasmissione")) + end + + it "genera il link, invia l'email e li mostra sui dettagli dopo il POST" do + login! + expect { + post public_team_invite_path(team), params: { email: "streamer@test.it", staff_kind: "transmission" } + }.to change { team.team_invitations.count }.by(1) + .and change { ActionMailer::Base.deliveries.size }.by(1) + + expect(response).to redirect_to(public_team_details_path(team, anchor: "invito-generato")) + follow_redirect! + + expect(response.body).to include("Invito inviato a streamer@test.it") + expect(response.body).to include("id=\"invito-generato\"") + expect(response.body).to include("/join/") + expect(response.body).to include("streamer@test.it") + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to eq(["streamer@test.it"]) + expect(mail.subject).to include("U13 Black") + expect(mail.body.encoded).to include("/join/") + end +end diff --git a/backend/spec/services/teams/staff_assignment_spec.rb b/backend/spec/services/teams/staff_assignment_spec.rb index 0e37ce9..b408208 100644 --- a/backend/spec/services/teams/staff_assignment_spec.rb +++ b/backend/spec/services/teams/staff_assignment_spec.rb @@ -1,10 +1,10 @@ require "rails_helper" RSpec.describe Teams::StaffAssignment do - let!(:owner) { User.create!(name: "Owner", email: "owner@test.com", password: "password123", role: "coach") } + let!(:owner) { User.create!(name: "Owner", email: "owner@test.com", password: "Password123", role: "coach") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } - let!(:other) { User.create!(name: "Other", email: "other@test.com", password: "password123", role: "coach") } + let!(:other) { User.create!(name: "Other", email: "other@test.com", password: "Password123", role: "coach") } before do ClubMembership.create!(user: owner, club: club, role: "owner")