Skip to content

Commit 022c5ca

Browse files
denis1011101claude
andcommitted
Address review notes on the training video notifications
- can_send_training_video? moves to ApplicationController: the checkbox is rendered by games/_media, which GamesController serves, so the helper declared on GameMediaController blew up games#show (CI red). - delivery fans out into one job per recipient with retries, so a transient email/telegram failure retries and ends up in failed jobs instead of being swallowed by a log line. - the notification names the actual uploader (an admin or an accepted coach can post the video too), falling back to neutral wording without a name. - a photo uploaded with the box ticked now says plainly that only videos are sent, instead of silently ignoring the choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ff33b57 commit 022c5ca

8 files changed

Lines changed: 123 additions & 29 deletions

File tree

app/controllers/application_controller.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ class ApplicationController < ActionController::Base
88
protect_from_forgery with: :exception
99
before_action :authenticate_user!
1010

11-
helper_method :current_user, :user_signed_in?, :sign_in, :sign_out, :geocoding_exceeded?, :can_manage?, :can_remove_participant?
11+
helper_method :current_user, :user_signed_in?, :sign_in, :sign_out, :geocoding_exceeded?, :can_manage?, :can_remove_participant?,
12+
:can_send_training_video?
1213

1314
private
1415

@@ -54,6 +55,15 @@ def can_remove_participant?(game, participation_user)
5455
AccessControl.can_remove_participant?(current_user, game, participation_user)
5556
end
5657

58+
# Рассылку ролика инициирует тот, кто ведёт игру: организатор, админ или
59+
# принятый тренер. Живёт здесь, а не в GameMediaController, потому что
60+
# галочку рисует games/_media, а его отдаёт GamesController.
61+
def can_send_training_video?(game)
62+
return false unless current_user
63+
64+
can_manage?(game) || (game.coach_id == current_user.id && game.coach_accepted?)
65+
end
66+
5767
def current_user
5868
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id].present?
5969
end

app/controllers/game_media_controller.rb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
class GameMediaController < ApplicationController
22
before_action :set_game
3-
helper_method :can_send_training_video?
43

54
# Прикладывать может организатор, админ и любой записавшийся участник:
65
# снимает обычно не тот, кто игру создал.
@@ -12,12 +11,7 @@ def create
1211
end
1312

1413
if medium.save
15-
if notify_participants?(medium)
16-
SendTrainingVideoJob.perform_later(medium.id)
17-
redirect_to @game, notice: t("game_media.uploaded_and_queued")
18-
else
19-
redirect_to @game, notice: t("game_media.uploaded")
20-
end
14+
redirect_to @game, notice: upload_notice(medium)
2115
else
2216
redirect_to @game, alert: medium.errors.full_messages.to_sentence.presence || t("game_media.failed"),
2317
status: :see_other
@@ -52,11 +46,25 @@ def contributor?
5246
@game.participations.approved.exists?(user_id: current_user.id)
5347
end
5448

49+
# Один и тот же file input принимает и фото, и видео, а рассылать мы умеем
50+
# только ролики. Поэтому галочка на фотографии не проглатывается молча:
51+
# файл сохраняем, но честно говорим, что рассылки не будет.
52+
def upload_notice(medium)
53+
if notify_participants?(medium)
54+
SendTrainingVideoJob.perform_later(medium.id)
55+
t("game_media.uploaded_and_queued")
56+
elsif notify_requested? && can_send_training_video?(@game)
57+
t("game_media.uploaded_videos_only")
58+
else
59+
t("game_media.uploaded")
60+
end
61+
end
62+
5563
def notify_participants?(medium)
56-
medium.video? && can_send_training_video?(@game) && ActiveModel::Type::Boolean.new.cast(params[:notify_participants])
64+
medium.video? && can_send_training_video?(@game) && notify_requested?
5765
end
5866

59-
def can_send_training_video?(game)
60-
can_manage?(game) || (game.coach == current_user && game.coach_accepted?)
67+
def notify_requested?
68+
ActiveModel::Type::Boolean.new.cast(params[:notify_participants])
6169
end
6270
end

app/jobs/send_training_video_job.rb

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,50 @@
11
class SendTrainingVideoJob < ApplicationJob
22
queue_as :default
33

4-
def perform(game_medium_id)
4+
# Отправка идёт по одной задаче на получателя: временный отказ почты или
5+
# телеграма роняет только свою доставку, она уходит в ретрай, а исчерпав
6+
# попытки — в failed jobs. Общий rescue вместо этого молча терял игрока.
7+
retry_on StandardError, wait: :polynomially_longer, attempts: 5
8+
9+
def perform(game_medium_id, user_id = nil)
510
medium = GameMedium.includes(file_attachment: :blob).find_by(id: game_medium_id)
611
return unless medium&.video? && !medium.hidden?
712

8-
notification = build_notification(medium)
9-
medium.game.participations.approved.where.not(user_id: nil).includes(:user).find_each do |participation|
10-
NotificationDelivery.deliver(user: participation.user, notification: notification)
11-
rescue StandardError => e
12-
Rails.logger.warn("SendTrainingVideoJob failed for user_id=#{participation.user_id}: #{e.message}")
13+
return deliver(medium, user_id) if user_id
14+
15+
medium.game.participations.approved.where.not(user_id: nil).find_each do |participation|
16+
self.class.perform_later(game_medium_id, participation.user_id)
1317
end
1418
end
1519

1620
private
1721

22+
def deliver(medium, user_id)
23+
user = User.find_by(id: user_id)
24+
return unless user
25+
26+
NotificationDelivery.deliver(user: user, notification: build_notification(medium))
27+
end
28+
1829
def build_notification(medium)
1930
url = Rails.application.routes.url_helpers.rails_blob_url(
2031
medium.file,
2132
**Rails.application.config.action_mailer.default_url_options.to_h.symbolize_keys
2233
)
34+
# Ролик может выложить не только организатор, но и админ или принятый
35+
# тренер, поэтому имя берём из самой загрузки. Без имени — нейтральный
36+
# текст: приписывать видео организатору наугад нельзя.
37+
author = medium.user&.name.to_s.strip.presence
2338

2439
NotificationDelivery::Notification.new(
2540
subject: ->(locale) { I18n.t("game_media.training_video.subject", locale: locale) },
26-
body: ->(locale) { I18n.t("game_media.training_video.body", locale: locale) },
41+
body: lambda { |locale|
42+
if author
43+
I18n.t("game_media.training_video.body_by", author: author, locale: locale)
44+
else
45+
I18n.t("game_media.training_video.body", locale: locale)
46+
end
47+
},
2748
actions: lambda { |locale|
2849
[ { label: I18n.t("game_media.training_video.action", locale: locale), url: url } ]
2950
}

config/locales/web.en.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ en:
7373
game_media:
7474
uploaded: Attachment uploaded.
7575
uploaded_and_queued: Video uploaded and queued for delivery to the players.
76+
uploaded_videos_only: Photo uploaded. Only videos can be sent to the players, so nothing was delivered.
7677
removed: Attachment removed.
7778
hidden: Attachment hidden from the feed.
7879
failed: Could not upload the attachment.
7980
not_allowed: Only the organizer and the players of this game can attach media.
8081
training_video:
8182
subject: Training video for your game
82-
body: The organizer shared a training video with the players of your game.
83+
body: A training video has been shared with the players of your game.
84+
body_by: "%{author} shared a training video with the players of your game."
8385
action: Watch video
8486
games:
8587
show:

config/locales/web.es.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ es:
7373
game_media:
7474
uploaded: Archivo subido.
7575
uploaded_and_queued: El vídeo se ha subido y está en cola para enviarlo a los jugadores.
76+
uploaded_videos_only: Foto subida. A los jugadores solo se les pueden enviar vídeos, así que no se ha enviado nada.
7677
removed: Archivo eliminado.
7778
hidden: Archivo oculto del feed.
7879
failed: No se ha podido subir el archivo.
7980
not_allowed: Solo el organizador y los jugadores del partido pueden adjuntar archivos.
8081
training_video:
8182
subject: Vídeo de entrenamiento para tu partido
82-
body: El organizador ha compartido un vídeo de entrenamiento con los jugadores de tu partido.
83+
body: Se ha compartido un vídeo de entrenamiento con los jugadores de tu partido.
84+
body_by: "%{author} ha compartido un vídeo de entrenamiento con los jugadores de tu partido."
8385
action: Ver vídeo
8486
games:
8587
show:

config/locales/web.ru.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ ru:
7373
game_media:
7474
uploaded: Вложение загружено.
7575
uploaded_and_queued: Видео загружено и поставлено в очередь на отправку игрокам.
76+
uploaded_videos_only: Фото загружено. Игрокам можно отправлять только видео, поэтому рассылки не было.
7677
removed: Вложение удалено.
7778
hidden: Вложение скрыто из ленты.
7879
failed: Не удалось загрузить вложение.
7980
not_allowed: Прикладывать фото и видео могут организатор и участники игры.
8081
training_video:
8182
subject: Обучающее видео для вашей игры
82-
body: Организатор поделился обучающим видео с участниками вашей игры.
83+
body: Обучающим видео поделились с участниками вашей игры.
84+
body_by: "%{author} поделился обучающим видео с участниками вашей игры."
8385
action: Смотреть видео
8486
games:
8587
show:

test/controllers/game_media_controller_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ class GameMediaControllerTest < ActionDispatch::IntegrationTest
4242
assert_redirected_to game_path(@game)
4343
end
4444

45+
test "a photo with the box ticked is uploaded but says nothing was sent" do
46+
sign_in_as @owner
47+
48+
assert_no_enqueued_jobs only: SendTrainingVideoJob do
49+
assert_difference -> { @game.game_media.count }, 1 do
50+
post game_media_path(@game), params: { file: upload, notify_participants: "1" }
51+
end
52+
end
53+
54+
assert_equal I18n.t("game_media.uploaded_videos_only"), flash[:notice]
55+
end
56+
4557
test "a player cannot queue an uploaded video for everyone" do
4658
sign_in_as @participant
4759

test/jobs/send_training_video_job_test.rb

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,7 @@ class SendTrainingVideoJobTest < ActiveJob::TestCase
2020
end
2121

2222
test "delivers a GetCourt video link only to approved registered players" do
23-
deliveries = []
24-
25-
with_stubbed_singleton_method(NotificationDelivery, :deliver, ->(**args) { deliveries << args }) do
26-
SendTrainingVideoJob.perform_now(@medium.id)
27-
end
23+
deliveries = deliver_all
2824

2925
assert_equal [ @approved.id ], deliveries.map { |delivery| delivery[:user].id }
3026
notification = deliveries.first[:notification]
@@ -33,19 +29,60 @@ class SendTrainingVideoJobTest < ActiveJob::TestCase
3329
assert_match %r{http://example.com/rails/active_storage/blobs/redirect/}, notification.actions(:en).first[:url]
3430
end
3531

32+
test "names the person who uploaded the video instead of the organizer" do
33+
coach = User.create!(name: "Coach Marina", email: "training-coach@example.com")
34+
@medium.update!(user: coach)
35+
36+
assert_equal "Coach Marina shared a training video with the players of your game.",
37+
deliver_all.first[:notification].body(:en)
38+
end
39+
40+
test "falls back to sender-neutral wording when the uploader has no name" do
41+
@medium.user.update_column(:name, nil)
42+
43+
assert_equal "A training video has been shared with the players of your game.",
44+
deliver_all.first[:notification].body(:en)
45+
end
46+
3647
test "does not deliver a hidden video" do
37-
deliveries = []
3848
@medium.hide!
3949

40-
with_stubbed_singleton_method(NotificationDelivery, :deliver, ->(**args) { deliveries << args }) do
50+
assert_empty deliver_all
51+
end
52+
53+
test "each approved player gets their own delivery job" do
54+
other = User.create!(email: "training-approved-2@example.com", locale: "en", notification_channel: "email")
55+
@game.participations.create!(user: other)
56+
57+
# Раскладка по получателям: одна задача на игрока, чтобы падение доставки
58+
# не уносило с собой остальных.
59+
assert_enqueued_jobs 2, only: SendTrainingVideoJob do
4160
SendTrainingVideoJob.perform_now(@medium.id)
4261
end
62+
end
4363

44-
assert_empty deliveries
64+
test "a failed delivery is retried instead of being swallowed" do
65+
failing = ->(**_args) { raise "telegram is down" }
66+
67+
assert_enqueued_with(job: SendTrainingVideoJob, args: [ @medium.id, @approved.id ]) do
68+
with_stubbed_singleton_method(NotificationDelivery, :deliver, failing) do
69+
SendTrainingVideoJob.perform_now(@medium.id, @approved.id)
70+
end
71+
end
4572
end
4673

4774
private
4875

76+
def deliver_all
77+
deliveries = []
78+
79+
with_stubbed_singleton_method(NotificationDelivery, :deliver, ->(**args) { deliveries << args }) do
80+
perform_enqueued_jobs(only: SendTrainingVideoJob) { SendTrainingVideoJob.perform_now(@medium.id) }
81+
end
82+
83+
deliveries
84+
end
85+
4986
def with_stubbed_singleton_method(target, method_name, replacement)
5087
singleton = target.singleton_class
5188
original = singleton.instance_method(method_name)

0 commit comments

Comments
 (0)