From 442fab10948757c7cfac261dbf1484cf6b2f969e Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Feb 2025 11:39:48 +0100 Subject: [PATCH] Add ability to email announcements to all users --- .../announcements/distributions_controller.rb | 18 +++++++++++ .../announcements/previews_controller.rb | 16 ++++++++++ .../admin/announcements/tests_controller.rb | 17 ++++++++++ app/mailers/user_mailer.rb | 9 ++++++ app/models/announcement.rb | 31 +++++++++++------- app/policies/announcement_policy.rb | 4 +++ .../announcements/_announcement.html.haml | 2 ++ .../announcements/previews/show.html.haml | 20 ++++++++++++ .../announcement_published.html.haml | 12 +++++++ .../announcement_published.text.erb | 7 ++++ ...ribute_announcement_notification_worker.rb | 15 +++++++++ config/locales/en.yml | 8 +++++ config/routes/admin.rb | 4 +++ ...d_notification_sent_at_to_announcements.rb | 7 ++++ db/schema.rb | 3 +- spec/mailers/user_mailer_spec.rb | 12 +++++++ ...e_announcement_notification_worker_spec.rb | 32 +++++++++++++++++++ 17 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 app/controllers/admin/announcements/distributions_controller.rb create mode 100644 app/controllers/admin/announcements/previews_controller.rb create mode 100644 app/controllers/admin/announcements/tests_controller.rb create mode 100644 app/views/admin/announcements/previews/show.html.haml create mode 100644 app/views/user_mailer/announcement_published.html.haml create mode 100644 app/views/user_mailer/announcement_published.text.erb create mode 100644 app/workers/admin/distribute_announcement_notification_worker.rb create mode 100644 db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb create mode 100644 spec/workers/admin/distribute_announcement_notification_worker_spec.rb diff --git a/app/controllers/admin/announcements/distributions_controller.rb b/app/controllers/admin/announcements/distributions_controller.rb new file mode 100644 index 0000000000..138c700e78 --- /dev/null +++ b/app/controllers/admin/announcements/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::Announcements::DistributionsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + @terms_of_service.touch(:notification_sent_at) + Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id) + redirect_to admin_announcements_index_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/previews_controller.rb b/app/controllers/admin/announcements/previews_controller.rb new file mode 100644 index 0000000000..d77f931a7f --- /dev/null +++ b/app/controllers/admin/announcements/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::Announcements::PreviewsController < Admin::BaseController + before_action :set_announcement + + def show + authorize @announcement, :distribute? + @user_count = @announcement.scope_for_notification.count + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/tests_controller.rb b/app/controllers/admin/announcements/tests_controller.rb new file mode 100644 index 0000000000..f2457eb23a --- /dev/null +++ b/app/controllers/admin/announcements/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::Announcements::TestsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + UserMailer.announcement_published(current_user, @announcement).deliver_later! + redirect_to admin_announcements_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index b02c462217..0eef9b90da 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -219,6 +219,15 @@ class UserMailer < Devise::Mailer end end + def announcement_published(user, announcement) + @resource = user + @announcement = announcement + + I18n.with_locale(locale) do + mail subject: default_i18n_subject + end + end + private def default_devise_subject diff --git a/app/models/announcement.rb b/app/models/announcement.rb index 54923ed081..aab55055a9 100644 --- a/app/models/announcement.rb +++ b/app/models/announcement.rb @@ -4,17 +4,18 @@ # # Table name: announcements # -# id :bigint(8) not null, primary key -# text :text default(""), not null -# published :boolean default(FALSE), not null -# all_day :boolean default(FALSE), not null -# scheduled_at :datetime -# starts_at :datetime -# ends_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# published_at :datetime -# status_ids :bigint(8) is an Array +# id :bigint(8) not null, primary key +# all_day :boolean default(FALSE), not null +# ends_at :datetime +# notification_sent_at :datetime +# published :boolean default(FALSE), not null +# published_at :datetime +# scheduled_at :datetime +# starts_at :datetime +# status_ids :bigint(8) is an Array +# text :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null # class Announcement < ApplicationRecord @@ -54,6 +55,10 @@ class Announcement < ApplicationRecord update!(published: false, scheduled_at: nil) end + def notification_sent? + notification_sent_at.present? + end + def mentions @mentions ||= Account.from_text(text) end @@ -86,6 +91,10 @@ class Announcement < ApplicationRecord end end + def scope_for_notification + User.confirmed.joins(:account).merge(Account.without_suspended) + end + private def grouped_ordered_announcement_reactions diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb index b5dc6a18af..907a3b1a86 100644 --- a/app/policies/announcement_policy.rb +++ b/app/policies/announcement_policy.rb @@ -16,4 +16,8 @@ class AnnouncementPolicy < ApplicationPolicy def destroy? role.can?(:manage_announcements) end + + def distribute? + record.published? && !record.notification_sent? && role.can?(:manage_settings) + end end diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml index 8190f87d2f..87ae97cf48 100644 --- a/app/views/admin/announcements/_announcement.html.haml +++ b/app/views/admin/announcements/_announcement.html.haml @@ -10,6 +10,8 @@ = l(announcement.created_at) %div + - if can?(:distribute, announcement) + = table_link_to 'mail', t('admin.terms_of_service.notify_users'), admin_announcement_preview_path(announcement) - if can?(:update, announcement) - if announcement.published? = table_link_to 'toggle_off', t('admin.announcements.unpublish'), unpublish_admin_announcement_path(announcement), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/announcements/previews/show.html.haml b/app/views/admin/announcements/previews/show.html.haml new file mode 100644 index 0000000000..fdfbf598b5 --- /dev/null +++ b/app/views/admin/announcements/previews/show.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('admin.announcements.preview.title') + +- content_for :heading_actions do + .back-link + = link_to admin_announcements_path do + = material_symbol 'chevron_left' + = t('admin.announcements.back') + +%p.lead + = t('admin.announcements.preview.explanation_html', count: @user_count, display_count: number_with_delimiter(@user_count)) + +.prose + = linkify(@announcement.text) + +%hr.spacer/ + +.content__heading__actions + = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_announcement_test_path(@announcement), method: :post, class: 'button button-secondary' + = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_announcement_distribution_path(@announcement), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') } diff --git a/app/views/user_mailer/announcement_published.html.haml b/app/views/user_mailer/announcement_published.html.haml new file mode 100644 index 0000000000..1a879e47c9 --- /dev/null +++ b/app/views/user_mailer/announcement_published.html.haml @@ -0,0 +1,12 @@ += content_for :heading do + = render 'application/mailer/heading', + image_url: frontend_asset_url('images/mailer-new/heading/user.png'), + title: t('user_mailer.announcement_published.title', domain: site_hostname) +%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-body-padding-td + %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-inner-card-td.email-prose + %p= t('user_mailer.announcement_published.description', domain: site_hostname) + = linkify(@announcement.text) diff --git a/app/views/user_mailer/announcement_published.text.erb b/app/views/user_mailer/announcement_published.text.erb new file mode 100644 index 0000000000..94bb4029dc --- /dev/null +++ b/app/views/user_mailer/announcement_published.text.erb @@ -0,0 +1,7 @@ +<%= t('user_mailer.announcement_published.title') %> + +=== + +<%= t('user_mailer.announcement_published.description', domain: site_hostname) %> + +<%= @announcement.text %> diff --git a/app/workers/admin/distribute_announcement_notification_worker.rb b/app/workers/admin/distribute_announcement_notification_worker.rb new file mode 100644 index 0000000000..a8b9a0bd94 --- /dev/null +++ b/app/workers/admin/distribute_announcement_notification_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Admin::DistributeAnnouncementNotificationWorker + include Sidekiq::Worker + + def perform(announcement_id) + announcement = Announcement.find(announcement_id) + + announcement.scope_for_notification.find_each do |user| + UserMailer.announcement_published(user, announcement).deliver_later! + end + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index fdf73bad9e..e0a93cc625 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -309,6 +309,7 @@ en: title: Audit log unavailable_instance: "(domain name unavailable)" announcements: + back: Back to announcements destroyed_msg: Announcement successfully deleted! edit: title: Edit announcement @@ -317,6 +318,9 @@ en: new: create: Create announcement title: New announcement + preview: + explanation_html: 'The email will be sent to %{display_count} users. The following text will be included in the e-mail:' + title: Preview announcement notification publish: Publish published_msg: Announcement successfully published! scheduled_for: Scheduled for %{time} @@ -1905,6 +1909,10 @@ en: recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. For example, you may print them and store them with other important documents. webauthn: Security keys user_mailer: + announcement_published: + description: 'The administrators of %{domain} are making an announcement:' + subject: Service announcement + title: "%{domain} service announcement" appeal_approved: action: Account Settings explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been approved. Your account is once again in good standing. diff --git a/config/routes/admin.rb b/config/routes/admin.rb index fd03b0d66d..d5459a2735 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -50,6 +50,10 @@ namespace :admin do post :publish post :unpublish end + + resource :preview, only: [:show], module: :announcements + resource :test, only: [:create], module: :announcements + resource :distribution, only: [:create], module: :announcements end with_options to: redirect('/admin/settings/branding') do diff --git a/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb b/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb new file mode 100644 index 0000000000..9cf1521a82 --- /dev/null +++ b/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddNotificationSentAtToAnnouncements < ActiveRecord::Migration[8.0] + def change + add_column :announcements, :notification_sent_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 6c4af6aa19..625d9c0c21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do +ActiveRecord::Schema[8.0].define(version: 2025_02_21_143646) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -258,6 +258,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do t.datetime "updated_at", precision: nil, null: false t.datetime "published_at", precision: nil t.bigint "status_ids", array: true + t.datetime "notification_sent_at" end create_table "annual_report_statuses_per_account_counts", force: :cascade do |t| diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 533aa2e624..3f40e24c8b 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -317,4 +317,16 @@ RSpec.describe UserMailer do .and(have_body_text(I18n.t('user_mailer.terms_of_service_changed.changelog'))) end end + + describe '#announcement_published' do + let(:announcement) { Fabricate :announcement } + let(:mail) { described_class.announcement_published(receiver, announcement) } + + it 'renders announcement_published mail' do + expect(mail) + .to be_present + .and(have_subject(I18n.t('user_mailer.announcement_published.subject'))) + .and(have_body_text(I18n.t('user_mailer.announcement_published.description', domain: Rails.configuration.x.local_domain))) + end + end end diff --git a/spec/workers/admin/distribute_announcement_notification_worker_spec.rb b/spec/workers/admin/distribute_announcement_notification_worker_spec.rb new file mode 100644 index 0000000000..0e618418b0 --- /dev/null +++ b/spec/workers/admin/distribute_announcement_notification_worker_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::DistributeAnnouncementNotificationWorker do + let(:worker) { described_class.new } + + describe '#perform' do + context 'with missing record' do + it 'runs without error' do + expect { worker.perform(nil) }.to_not raise_error + end + end + + context 'with valid announcement' do + let(:announcement) { Fabricate(:announcement) } + let!(:user) { Fabricate :user, confirmed_at: 3.days.ago } + + it 'sends the announcement via email', :inline_jobs do + emails = capture_emails { worker.perform(announcement.id) } + + expect(emails.size) + .to eq(1) + expect(emails.first) + .to have_attributes( + to: [user.email], + subject: I18n.t('user_mailer.announcement_published.subject') + ) + end + end + end +end