diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cbe6a459e58..f7565912e20 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -56,7 +56,7 @@ Metrics/BlockNesting: # Offense count: 23 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 345 + Max: 342 # Offense count: 72 # Configuration parameters: AllowedMethods, AllowedPatterns. diff --git a/app/controllers/preferences/notification_preferences_controller.rb b/app/controllers/preferences/notification_preferences_controller.rb new file mode 100644 index 00000000000..719910b1f2e --- /dev/null +++ b/app/controllers/preferences/notification_preferences_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Preferences + class NotificationPreferencesController < PreferencesController + private + + def update_preferences + current_user.notification_preferences.update(params[:user_notification_preferences]) + end + end +end diff --git a/app/helpers/user_notification_preferences_helper.rb b/app/helpers/user_notification_preferences_helper.rb new file mode 100644 index 00000000000..74ba534647f --- /dev/null +++ b/app/helpers/user_notification_preferences_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module UserNotificationPreferencesHelper + def user_notification_events + UserNotificationPreferences::EVENTS + end + + def user_notification_mechanisms + UserNotificationPreferences::MECHANISMS + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 07764f124f0..ceee49b5561 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -294,6 +294,10 @@ def default_diary_language=(language) preference.update!(:v => language) end + def notification_preferences + @notification_preferences ||= UserNotificationPreferences.new(self) + end + def home_location? home_lat && home_lon end diff --git a/app/models/user_notification_preferences.rb b/app/models/user_notification_preferences.rb new file mode 100644 index 00000000000..5653c2418d0 --- /dev/null +++ b/app/models/user_notification_preferences.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class UserNotificationPreferences + extend ActiveModel::Naming + include ActiveModel::Conversion + + EVENTS = %w[ + changeset_comment + diary_comment + direct_message + gpx_import_failure + gpx_import_success + new_follower + note_comment + ].freeze + + MECHANISMS = %w[ + email + ].freeze + + def initialize(user) + @user = user + end + + # Receives a hash in the form `event => [mechanisms...]`. Eg: + # `{:changeset_comment => ["email"], :new_follower => []}` + def update(new_prefs) + updated_records = + EVENTS.map do |event_name| + MECHANISMS.filter_map do |mechanism| + next unless new_prefs.key?(event_name) + + record = @user.preferences.find_or_initialize_by(:k => "notification.#{event_name}.#{mechanism}") + record.v = Array.wrap(new_prefs[event_name]).include?(mechanism) + record + end + end.flatten + + UserPreference.transaction do + updated_records.each(&:save!) + true + end + end + + # One getter method for each event. Required by ActionView + # to render the form, but also generally useful to us. + EVENTS.each do |event_name| + define_method event_name do + prefs = + @user + .preferences + .where("k LIKE 'notification.#{event_name}.%'") + .pluck(:k, :v) + .to_h + .transform_keys { |k| k.split(".").last } + + MECHANISMS.filter do |mechanism| + prefs.key?(mechanism) ? ActiveModel::Type::Boolean.new.cast(prefs[mechanism]) : true + end + end + end +end diff --git a/app/notifiers/changeset_comment_notifier.rb b/app/notifiers/changeset_comment_notifier.rb index b3291cd70b5..5cc56139777 100644 --- a/app/notifiers/changeset_comment_notifier.rb +++ b/app/notifiers/changeset_comment_notifier.rb @@ -8,5 +8,6 @@ class ChangesetCommentNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "changeset_comment_notification" + config.if = -> { recipient.notification_preferences.changeset_comment.include?("email") } end end diff --git a/app/notifiers/diary_comment_notifier.rb b/app/notifiers/diary_comment_notifier.rb index afe992ab6a9..0ef515d8e91 100644 --- a/app/notifiers/diary_comment_notifier.rb +++ b/app/notifiers/diary_comment_notifier.rb @@ -8,5 +8,6 @@ class DiaryCommentNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "diary_comment_notification" + config.if = -> { recipient.notification_preferences.diary_comment.include?("email") } end end diff --git a/app/notifiers/direct_message_notifier.rb b/app/notifiers/direct_message_notifier.rb index 7814195640f..7374695e6e0 100644 --- a/app/notifiers/direct_message_notifier.rb +++ b/app/notifiers/direct_message_notifier.rb @@ -8,5 +8,6 @@ class DirectMessageNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "message_notification" + config.if = -> { recipient.notification_preferences.direct_message.include?("email") } end end diff --git a/app/notifiers/gpx_import_failure_notifier.rb b/app/notifiers/gpx_import_failure_notifier.rb index 598b39a0530..0b6cbe1012a 100644 --- a/app/notifiers/gpx_import_failure_notifier.rb +++ b/app/notifiers/gpx_import_failure_notifier.rb @@ -4,5 +4,6 @@ class GpxImportFailureNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "gpx_failure" + config.if = -> { recipient.notification_preferences.gpx_import_failure.include?("email") } end end diff --git a/app/notifiers/gpx_import_success_notifier.rb b/app/notifiers/gpx_import_success_notifier.rb index 6edf67fbf0e..b0beda515be 100644 --- a/app/notifiers/gpx_import_success_notifier.rb +++ b/app/notifiers/gpx_import_success_notifier.rb @@ -8,5 +8,6 @@ class GpxImportSuccessNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "gpx_success" + config.if = -> { recipient.notification_preferences.gpx_import_success.include?("email") } end end diff --git a/app/notifiers/new_follower_notifier.rb b/app/notifiers/new_follower_notifier.rb index 1508e206421..12c9e0a691b 100644 --- a/app/notifiers/new_follower_notifier.rb +++ b/app/notifiers/new_follower_notifier.rb @@ -8,5 +8,6 @@ class NewFollowerNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "follow_notification" + config.if = -> { recipient.notification_preferences.new_follower.include?("email") } end end diff --git a/app/notifiers/note_comment_notifier.rb b/app/notifiers/note_comment_notifier.rb index 03cbb17941c..98e4a678078 100644 --- a/app/notifiers/note_comment_notifier.rb +++ b/app/notifiers/note_comment_notifier.rb @@ -8,5 +8,6 @@ class NoteCommentNotifier < ApplicationNotifier deliver_by :email do |config| config.mailer = "UserMailer" config.method = "note_comment_notification" + config.if = -> { recipient.notification_preferences.note_comment.include?("email") } end end diff --git a/app/views/preferences/notification_preferences/show.html.erb b/app/views/preferences/notification_preferences/show.html.erb new file mode 100644 index 00000000000..0ab2c016b62 --- /dev/null +++ b/app/views/preferences/notification_preferences/show.html.erb @@ -0,0 +1,27 @@ +<% content_for :heading_class, "pb-0" %> + +<% content_for :heading do %> +

<%= t ".title" %>

+ <%= render :partial => "navigation" %> +<% end %> + +<%= bootstrap_form_for current_user.notification_preferences, :method => :patch, :url => { :action => :update } do |f| %> +
+ <% user_notification_events.each do |event_name| %> +
+ <%= f.collection_check_boxes( + event_name, + user_notification_mechanisms, + :itself, + ->(mechanism) { t(".delivery_mechanisms.#{mechanism}") }, + :label => { + :text => t(".#{event_name}"), + :class => "fw-bold fs-6" + } + ) %> +
+ <% end %> +
+ + <%= f.primary t(".save") %> +<% end %> diff --git a/app/views/preferences/preferences/_navigation.html.erb b/app/views/preferences/preferences/_navigation.html.erb index 00b7cf7bc06..cd6f70e3838 100644 --- a/app/views/preferences/preferences/_navigation.html.erb +++ b/app/views/preferences/preferences/_navigation.html.erb @@ -4,6 +4,9 @@ + diff --git a/config/locales/en.yml b/config/locales/en.yml index fea147731f1..050e9c9b2a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2113,8 +2113,9 @@ en: update_success_flash: message: Preferences updated. navigation: - preferences: Preferences - advanced_preferences: Advanced Preferences + preferences: Interface + notification_preferences: Notifications + advanced_preferences: Advanced basic_preferences: show: title: My Preferences @@ -2135,6 +2136,19 @@ en: light: Light dark: Dark save: Update Preferences + notification_preferences: + show: + title: Notification Preferences + changeset_comment: Changeset comments + diary_comment: Diary comments + direct_message: Direct messages + gpx_import_failure: "GPS trace import: failure" + gpx_import_success: "GPX trace import: success" + new_follower: New followers + note_comment: Note comments + save: Update Preferences + delivery_mechanisms: + email: Email advanced_preferences: show: title: My Advanced Preferences diff --git a/config/routes.rb b/config/routes.rb index c0cf75d7e75..e63bed58e68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -339,6 +339,7 @@ scope :preferences, :module => :preferences do resource :basic_preferences, :path => "basic", :only => [:show, :update] + resource :notification_preferences, :path => "notifications", :only => [:show, :update] resource :advanced_preferences, :path => "advanced", :only => [:show, :update] end get "/preferences", :to => redirect(:path => "/preferences/basic"), :as => nil diff --git a/test/models/user_notification_preferences_test.rb b/test/models/user_notification_preferences_test.rb new file mode 100644 index 00000000000..473a9eb6c6b --- /dev/null +++ b/test/models/user_notification_preferences_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "test_helper" + +class UserNotificationPreferencesTest < ActiveSupport::TestCase + def test_all_enabled_by_default + preferences = UserNotificationPreferences.new(create(:user)) + assert_equal ["email"], preferences.changeset_comment + assert_equal ["email"], preferences.diary_comment + assert_equal ["email"], preferences.direct_message + assert_equal ["email"], preferences.gpx_import_failure + assert_equal ["email"], preferences.gpx_import_success + assert_equal ["email"], preferences.new_follower + assert_equal ["email"], preferences.note_comment + end + + def test_update + preferences = UserNotificationPreferences.new(create(:user)) + preferences.update( + "changeset_comment" => ["email"], + "diary_comment" => [], + "direct_message" => ["email"], + "gpx_import_failure" => ["email"], + "gpx_import_success" => nil, + "new_follower" => [] + ) + + assert_equal ["email"], preferences.changeset_comment + assert_equal [], preferences.diary_comment + assert_equal ["email"], preferences.direct_message + assert_equal ["email"], preferences.gpx_import_failure + assert_equal [], preferences.gpx_import_success + assert_equal [], preferences.new_follower + + # Default value + assert_equal ["email"], preferences.note_comment + end + + def test_update_ignore_invalid_values + preferences = UserNotificationPreferences.new(create(:user)) + + preferences.update("changeset_comment" => ["whatsapp"]) + assert_equal [], preferences.changeset_comment + assert_equal 0, UserPreference.where("k LIKE '%whatsapp%'").count + + preferences.update("changeset_comment" => %w[whatsapp email]) + assert_equal ["email"], preferences.changeset_comment + assert_equal 0, UserPreference.where("k LIKE '%whatsapp%'").count + + preferences.update("imaginary_event" => ["email"]) + assert_equal 0, UserPreference.where("k LIKE '%imaginary_event%'").count + end + + def test_update_ignore_unmentioned_events + preferences = UserNotificationPreferences.new(create(:user)) + preferences.update( + "changeset_comment" => ["email"], + "diary_comment" => [] + ) + assert_equal 1, count_event_preferences_in_db("changeset_comment") + assert_equal 1, count_event_preferences_in_db("diary_comment") + assert_equal 0, count_event_preferences_in_db("direct_message") + assert_equal 0, count_event_preferences_in_db("gpx_import_failure") + assert_equal 0, count_event_preferences_in_db("gpx_import_success") + assert_equal 0, count_event_preferences_in_db("new_follower") + assert_equal 0, count_event_preferences_in_db("note_comment") + assert_equal 0, count_event_preferences_in_db("note_comment") + end + + private + + def count_event_preferences_in_db(event_name) + # A bit paranoid, but want to avoid misspellings, etc that produce + # false positives. + raise "Unknown event #{event_name.inspect}" unless UserNotificationPreferences::EVENTS.include?(event_name) + + UserPreference.where("k LIKE 'notification.#{event_name}.%'").count + end +end diff --git a/test/notifiers/changeset_notifier_test.rb b/test/notifiers/changeset_notifier_test.rb new file mode 100644 index 00000000000..3abf6653d22 --- /dev/null +++ b/test/notifiers/changeset_notifier_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "test_helper" + +class ChangesetCommentNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("changeset_comment" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("changeset_comment" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(changeset_author) + changeset = create(:changeset, :user => changeset_author) + create(:changeset_subscription, :changeset => changeset, :subscriber => changeset_author) + + comment_author = create(:user) + changeset_comment = create(:changeset_comment, :author => comment_author, :changeset => changeset) + + perform_enqueued_jobs do + Nominatim.stub :describe_location, nil do + ChangesetCommentNotifier.with(:record => changeset_comment).deliver + end + end + end +end diff --git a/test/notifiers/diary_comment_notifier_test.rb b/test/notifiers/diary_comment_notifier_test.rb new file mode 100644 index 00000000000..3c54ee3dc04 --- /dev/null +++ b/test/notifiers/diary_comment_notifier_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "test_helper" + +class DiaryCommentNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("diary_comment" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("diary_comment" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(diary_author) + create(:language, :code => "en") + diary_entry = create(:diary_entry, :user => diary_author) + create(:diary_entry_subscription, :diary_entry => diary_entry, :user => diary_author) + + comment_author = create(:user) + diary_comment = create(:diary_comment, :user => comment_author, :diary_entry => diary_entry) + + perform_enqueued_jobs do + DiaryCommentNotifier.with(:record => diary_comment).deliver + end + end +end diff --git a/test/notifiers/direct_message_notifier_test.rb b/test/notifiers/direct_message_notifier_test.rb new file mode 100644 index 00000000000..cc311ac8b10 --- /dev/null +++ b/test/notifiers/direct_message_notifier_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +class DirectMessageNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("direct_message" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("direct_message" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(message_recipient) + message = create(:message, :recipient => message_recipient) + + perform_enqueued_jobs do + DirectMessageNotifier.with(:record => message).deliver + end + end +end diff --git a/test/notifiers/gpx_import_failure_notifier_test.rb b/test/notifiers/gpx_import_failure_notifier_test.rb new file mode 100644 index 00000000000..2064a3432e1 --- /dev/null +++ b/test/notifiers/gpx_import_failure_notifier_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" + +class GpxImportFailureNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("gpx_import_failure" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("gpx_import_failure" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(trace_author) + perform_enqueued_jobs do + GpxImportFailureNotifier.with( + :trace_name => "My trace", + :trace_description => "There are others like it, etc", + :trace_tags => %w[vegetarian assume elapse], + :error => "I'm sorry, Dave" + ).deliver(trace_author) + end + end +end diff --git a/test/notifiers/gpx_import_success_notifier_test.rb b/test/notifiers/gpx_import_success_notifier_test.rb new file mode 100644 index 00000000000..8bc8b0135dc --- /dev/null +++ b/test/notifiers/gpx_import_success_notifier_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +class GpxImportSuccessNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("gpx_import_success" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("gpx_import_success" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(trace_author) + trace = create(:trace, :user => trace_author) + + perform_enqueued_jobs do + GpxImportSuccessNotifier.with(:record => trace, :possible_points => 5).deliver + end + end +end diff --git a/test/notifiers/new_follower_notifier_test.rb b/test/notifiers/new_follower_notifier_test.rb new file mode 100644 index 00000000000..d6ef0c4a17c --- /dev/null +++ b/test/notifiers/new_follower_notifier_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" + +class NewFollowerNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("new_follower" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("new_follower" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(followee) + follow = create(:follow, :following => followee) + perform_enqueued_jobs do + NewFollowerNotifier.with(:record => follow).deliver + end + end +end diff --git a/test/notifiers/note_comment_notifier_test.rb b/test/notifiers/note_comment_notifier_test.rb new file mode 100644 index 00000000000..8249aff6afe --- /dev/null +++ b/test/notifiers/note_comment_notifier_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "test_helper" + +class NoteCommentNotifierTest < ActiveSupport::TestCase + def setup + ActionMailer::Base.deliveries.clear + end + + def test_send_email_when_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("note_comment" => ["email"]) + + trigger_notification(candidate_recipient) + + email = ActionMailer::Base.deliveries.first + assert_equal [candidate_recipient.email], email.to + end + + def test_do_not_send_email_when_not_subscribed + candidate_recipient = create(:user) + candidate_recipient.notification_preferences.update("note_comment" => []) + + trigger_notification(candidate_recipient) + + assert_empty ActionMailer::Base.deliveries + end + + private + + def trigger_notification(note_author) + note = create(:note, :author => note_author) + create(:note_subscription, :note => note, :user => note_author) + + comment_author = create(:user) + note_comment = create(:note_comment, :author => comment_author, :note => note) + + perform_enqueued_jobs do + Nominatim.stub :describe_location, nil do + NoteCommentNotifier.with(:record => note_comment).deliver + end + end + end +end diff --git a/test/system/notification_preferences_test.rb b/test/system/notification_preferences_test.rb new file mode 100644 index 00000000000..2471616c10a --- /dev/null +++ b/test/system/notification_preferences_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "application_system_test_case" + +class NotificationPreferencesTest < ApplicationSystemTestCase + test "toggling preferences" do + ActionMailer::Base.deliveries.clear + + user = create(:user) + sign_in_as(user) + + visit notification_preferences_path + + assert_selector ".notification_preferences input:checked", :count => 7 + + follow1 = create(:follow, :following => user) + perform_enqueued_jobs do + NewFollowerNotifier.with(:record => follow1).deliver + end + email = ActionMailer::Base.deliveries.first + assert_equal 1, email.to.count + assert_equal user.email, email.to.first + ActionMailer::Base.deliveries.clear + + uncheck "user_notification_preferences_new_follower_email" + uncheck "user_notification_preferences_gpx_import_success_email" + click_on "Update Preferences" + + assert_selector ".notification_preferences input:checked", :count => 5 + assert_selector "input#user_notification_preferences_new_follower_email:not(checked)" + + follow2 = create(:follow, :following => user) + perform_enqueued_jobs do + NewFollowerNotifier.with(:record => follow2).deliver + end + assert_empty ActionMailer::Base.deliveries + end +end