Feature/1899 send daily email summaries (#9430)

* email digests

* use time for notification digest setting

* safeguard against empty digest
pull/9456/head
ulferts 3 years ago committed by GitHub
parent 2ee3a06c63
commit 75bb809ad6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 34
      app/contracts/notifications/create_contract.rb
  2. 48
      app/helpers/mail_digest_helper.rb
  3. 6
      app/helpers/settings_helper.rb
  4. 6
      app/mailers/application_mailer.rb
  5. 74
      app/mailers/digest_mailer.rb
  6. 2
      app/mailers/member_mailer.rb
  7. 2
      app/mailers/project_mailer.rb
  8. 2
      app/mailers/user_mailer.rb
  9. 7
      app/models/notification.rb
  10. 2
      app/models/notification_setting.rb
  11. 45
      app/models/notifications/scopes/mail_digest_before.rb
  12. 1
      app/seeders/admin_user_seeder.rb
  13. 42
      app/services/notifications/create_service.rb
  14. 78
      app/services/notifications/journal_wp_notification_service.rb
  15. 12
      app/services/notifications/set_attributes_service.rb
  16. 5
      app/services/users/set_attributes_service.rb
  17. 13
      app/views/admin/settings/mail_notifications_settings/show.html.erb
  18. 7
      app/views/admin/settings/notifications_settings/show.html.erb
  19. 56
      app/views/digest_mailer/work_packages.html.erb
  20. 36
      app/views/digest_mailer/work_packages.text.erb
  21. 43
      app/workers/mails/deliver_job.rb
  22. 83
      app/workers/mails/digest_job.rb
  23. 4
      app/workers/mails/watcher_job.rb
  24. 49
      app/workers/mails/with_sender.rb
  25. 4
      app/workers/mails/work_package_job.rb
  26. 2
      config/locales/crowdin/de.yml
  27. 24
      config/locales/en.yml
  28. 1
      config/locales/js-en.yml
  29. 8
      config/settings.yml
  30. 6
      db/migrate/20210618132206_add_notification_settings.rb
  31. 43
      db/migrate/20210701073944_add_digest_setting.rb
  32. 12
      db/migrate/20210701082511_add_digest_to_notification.rb
  33. 4
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.html
  34. 12
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.ts
  35. 3
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.ts
  36. 4
      frontend/src/app/features/user-preferences/state/notification-setting.model.ts
  37. 2
      frontend/src/global_styles/content/_forms.sass
  38. 22
      lib/api/v3/notifications/notification_representer.rb
  39. 16
      lib/api/v3/notifications/notifications_api.rb
  40. 16
      lib/api/v3/utilities/path_helper.rb
  41. 2
      lib/open_project/form_tag_helper.rb
  42. 17
      lib/redmine/i18n.rb
  43. 112
      spec/contracts/notifications/create_contract_spec.rb
  44. 7
      spec/factories/notification_factory.rb
  45. 4
      spec/factories/notification_setting_factory.rb
  46. 3
      spec/factories/user_factory.rb
  47. 109
      spec/features/notifications/digest_mail_spec.rb
  48. 5
      spec/features/notifications/my_notifications_settings_spec.rb
  49. 20
      spec/features/users/notifications/shared_examples.rb
  50. 4
      spec/features/users/notifications/user_notifications_settings_spec.rb
  51. 39
      spec/helpers/settings_helper_spec.rb
  52. 34
      spec/lib/api/v3/notifications/notification_representer_rendering_spec.rb
  53. 24
      spec/lib/api/v3/utilities/path_helper_spec.rb
  54. 121
      spec/mailers/digest_mailer_spec.rb
  55. 1
      spec/mailers/member_mailer_spec.rb
  56. 92
      spec/models/notifications/scopes/mail_digest_before_spec.rb
  57. 2
      spec/models/queries/notifications/notification_query_spec.rb
  58. 116
      spec/requests/api/v3/notifications/bulk_read_email_resource_spec.rb
  59. 116
      spec/requests/api/v3/notifications/bulk_unread_email_resource_spec.rb
  60. 82
      spec/requests/api/v3/notifications/read_email_resource_spec.rb
  61. 3
      spec/requests/api/v3/notifications/show_resource_examples.rb
  62. 82
      spec/services/notifications/create_service_spec.rb
  63. 362
      spec/services/notifications/journal_wp_notification_service_spec.rb
  64. 31
      spec/services/notifications/set_attributes_service_spec.rb
  65. 2
      spec/services/user_preferences/update_service_integration_spec.rb
  66. 2
      spec/services/users/set_attributes_service_spec.rb
  67. 39
      spec/support/pages/my/notifications.rb
  68. 10
      spec/support/pages/notifications/settings.rb
  69. 152
      spec/workers/mails/digest_job_spec.rb

@ -28,19 +28,23 @@
module Notifications
class CreateContract < ::ModelContract
CHANNELS = %i[ian mail mail_digest].freeze
attribute :recipient
attribute :subject
attribute :reason
attribute :reason_ian
attribute :reason_mail
attribute :reason_mail_digest
attribute :project
attribute :actor
attribute :resource
attribute :journal
attribute :resource_type
attribute :read_ian
attribute :read_email
attribute :read_mail
attribute :read_mail_digest
validate :validate_recipient_present
validate :validate_subject_present
validate :validate_reason_present
validate :validate_channels
@ -48,26 +52,28 @@ module Notifications
errors.add(:recipient, :blank) if model.recipient.blank?
end
def validate_subject_present
errors.add(:subject, :blank) if model.subject.blank?
end
def validate_reason_present
errors.add(:reason, :blank) if model.reason.blank?
CHANNELS.each do |channel|
errors.add(:"reason_#{channel}", :no_notification_reason) if read_channel_without_reason?(channel)
end
end
def validate_channels
if model.read_ian == nil && model.read_email == nil
if CHANNELS.map { |channel| read_channel(channel) }.compact.empty?
errors.add(:base, :at_least_one_channel)
end
if model.read_ian
errors.add(:read_ian, :read_on_creation)
CHANNELS.each do |channel|
errors.add(:"read_#{channel}", :read_on_creation) if read_channel(channel)
end
end
if model.read_email
errors.add(:read_email, :read_on_creation)
end
def read_channel_without_reason?(channel)
read_channel(channel) == false && model.send(:"reason_#{channel}").nil?
end
def read_channel(channel)
model.send(:"read_#{channel}")
end
end
end

@ -0,0 +1,48 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module MailDigestHelper
def digest_timespan_text
end_time = Time.parse(Setting.notification_email_digest_time)
I18n.t(:"mail.digests.time_frame",
start: format_time(end_time - 1.day),
end: format_time(end_time))
end
def digest_notification_timestamp_text(notification, html: true)
journal = notification.journal
user = html ? link_to_user(journal.user, only_path: false) : journal.user.name
raw(I18n.t(:"mail.digests.work_packages.#{journal.initial? ? 'created_at' : 'updated_at'}",
user: user,
timestamp: format_time(journal.created_at)))
end
end

@ -118,6 +118,12 @@ module SettingsHelper
end
end
def setting_time_field(setting, options = {})
setting_field_wrapper(setting, options) do
styled_time_field_tag("settings[#{setting}]", Setting.send(setting), options)
end
end
def setting_field_wrapper(setting, options)
unit = options.delete(:unit)
unit_html = ''

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class BaseMailer < ActionMailer::Base
class ApplicationMailer < ActionMailer::Base
layout 'mailer'
helper :application, # for format_text
@ -55,8 +55,8 @@ class BaseMailer < ActionMailer::Base
def generate_message_id(object, user)
# id + timestamp should reduce the odds of a collision
# as far as we don't send multiple emails for the same object
journable = (object.is_a? Journal) ? object.journable : object
# as long as we don't send multiple emails for the same object
journable = object.is_a?(Journal) ? object.journable : object
timestamp = mail_timestamp(object)
hash = 'openproject'\

@ -0,0 +1,74 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
# Sends digest mails. Digest mails contain the combined information of multiple updates to
# resources.
# Currently, this is limited to work packages
class DigestMailer < ApplicationMailer
include OpenProject::StaticRouting::UrlHelpers
include OpenProject::TextFormatting
include Redmine::I18n
helper :mail_digest
class << self
def generate_message_id(_, user)
hash = "openproject.digest-#{user.id}-#{Time.current.strftime('%Y%m%d%H%M%S')}"
host = Setting.mail_from.to_s.gsub(%r{\A.*@}, '')
host = "#{::Socket.gethostname}.openproject" if host.empty?
"#{hash}@#{host}"
end
end
def work_packages(recipient_id, notification_ids)
recipient = User.find(recipient_id)
open_project_headers User: recipient.name
message_id nil, recipient
@notifications_by_project = load_notifications(notification_ids)
.group_by(&:project)
.transform_values { |of_project| of_project.group_by(&:resource) }
mail to: recipient.mail,
subject: I18n.t('mail.digests.work_packages.subject',
date: format_time_as_date(Time.current),
number: notification_ids.count)
end
protected
def load_notifications(notification_ids)
Notification
.where(id: notification_ids)
.includes(:project, :resource, journal: %i[data attachable_journals customizable_journals])
end
end

@ -39,7 +39,7 @@
# The mailer does not fan out in case a group is provided. The individual members of a group
# need to be mailed to individually.
class MemberMailer < BaseMailer
class MemberMailer < ApplicationMailer
include OpenProject::StaticRouting::UrlHelpers
include OpenProject::TextFormatting

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class ProjectMailer < BaseMailer
class ProjectMailer < ApplicationMailer
def delete_project_completed(project, user:)
open_project_headers Project: project.identifier,
Author: user.login

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class UserMailer < BaseMailer
class UserMailer < ApplicationMailer
def test_mail(user)
@welcome_url = url_for(controller: '/homescreen')

@ -1,5 +1,7 @@
class Notification < ApplicationRecord
enum reason: { mentioned: 0, involved: 1, watched: 2, subscribed: 3 }
enum reason_ian: { mentioned: 0, involved: 1, watched: 2, subscribed: 3 }, _prefix: :ian
enum reason_mail: { mentioned: 0, involved: 1, watched: 2, subscribed: 3 }, _prefix: :mail
enum reason_mail_digest: { mentioned: 0, involved: 1, watched: 2, subscribed: 3 }, _prefix: :mail_digest
belongs_to :recipient, class_name: 'User'
belongs_to :actor, class_name: 'User'
@ -8,4 +10,7 @@ class Notification < ApplicationRecord
belongs_to :resource, polymorphic: true
scope :recipient, ->(user) { where(recipient_id: user.is_a?(User) ? user.id : user) }
include Scopes::Scoped
scopes :mail_digest_before
end

@ -1,5 +1,5 @@
class NotificationSetting < ApplicationRecord
enum channel: { in_app: 0, mail: 1 }
enum channel: { in_app: 0, mail: 1, mail_digest: 2 }
belongs_to :project
belongs_to :user

@ -0,0 +1,45 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Notifications::Scopes
module MailDigestBefore
extend ActiveSupport::Concern
class_methods do
# Return notifications of the user for which mail digest is to be sent and that is created before
# the specified time.
def mail_digest_before(recipient:, time:)
where(Notification.arel_table[:created_at].lteq(time))
.where(recipient: recipient)
.where(read_mail_digest: false)
end
end
end
end

@ -59,6 +59,7 @@ class AdminUserSeeder < Seeder
user.force_password_change = force_password_change?
user.notification_settings.build(channel: :mail, involved: true, mentioned: true, watched: true)
user.notification_settings.build(channel: :in_app, involved: true, mentioned: true, watched: true)
user.notification_settings.build(channel: :mail_digest, involved: true, mentioned: true, watched: true)
end
end

@ -33,13 +33,41 @@ class Notifications::CreateService < ::BaseServices::Create
def after_perform(call)
super.tap do |super_call|
# Explicitly checking for false here as the nil value means that no
# notification should be sent at all.
if super_call.success? && super_call.result.read_email == false
Mails::NotificationJob
.set(wait: Setting.notification_email_delay_minutes.minutes)
.perform_later(super_call.result)
end
schedule_mail_jobs(super_call.result) if super_call.success?
end
end
def schedule_mail_jobs(notification)
# Explicitly checking for false here as the nil value means that no
# notification should be sent at all.
schedule_direct_mail_job(notification) if notification.read_mail == false
schedule_digest_mail_job(notification) if notification.read_mail_digest == false
end
def schedule_direct_mail_job(notification)
Mails::NotificationJob
.set(wait: Setting.notification_email_delay_minutes.minutes)
.perform_later(notification)
end
def schedule_digest_mail_job(notification)
# This alone is vulnerable to the edge case of the Mails::DigestJob
# having started but not completed when a new digest notification is generated.
# To cope with it, the Mails::DigestJob as its first action sets all digest notifications
# to being handled even though they are still processed.
# See the DigestJob for more details.
return if digest_job_already_scheduled?(notification)
Mails::DigestJob
.set(wait_until: Mails::DigestJob.execution_time(notification.recipient))
.perform_later(notification.recipient)
end
def digest_job_already_scheduled?(notification)
Notification
.mail_digest_before(recipient: notification.recipient,
time: notification.created_at)
.where.not(id: notification.id)
.exists?
end
end

@ -43,86 +43,82 @@ class Notifications::JournalWpNotificationService
def call(journal, send_notifications)
return nil if abort_sending?(journal, send_notifications)
author = User.find_by(id: journal.user_id) || DeletedUser.first
notification_receivers(journal.journable, journal).each do |recipient_id, channel_reasons|
create_event(journal,
recipient_id,
channel_reasons.keys,
# TODO: split up into two separate reasons
# For now, we prepare the in_app reason since that one is displayed.
channel_reasons['in_app'].first || channel_reasons['mail'].first,
author)
create_notification(journal,
recipient_id,
channel_reasons)
end
end
private
def create_event(journal, recipient_id, channels, reason, user)
def create_notification(journal, recipient_id, channel_reasons)
notification_attributes = {
recipient_id: recipient_id,
reason: reason,
project: journal.project,
resource: journal.journable,
journal: journal,
actor: journal.user
}.merge(channel_attributes(channels))
actor: user_with_fallback(journal)
}.merge(channel_attributes(channel_reasons))
Notifications::CreateService
.new(user: user)
.new(user: user_with_fallback(journal))
.call(notification_attributes)
end
def channel_attributes(channels)
def channel_attributes(channel_reasons)
{
read_email: channels.include?('mail') ? false : nil,
read_ian: channels.include?('in_app') ? false : nil
read_mail: channel_reasons.keys.include?('mail') ? false : nil,
read_mail_digest: channel_reasons.keys.include?('mail_digest') ? false : nil,
read_ian: channel_reasons.keys.include?('in_app') ? false : nil,
reason_ian: channel_reasons['in_app']&.first,
reason_mail: channel_reasons['mail']&.first,
reason_mail_digest: channel_reasons['mail_digest']&.first
}
end
def notification_receivers(work_package, journal)
receivers = receivers_hash
add_receiver(receivers, mentioned(journal), :mentioned)
add_receiver(receivers, involved(journal), :involved)
add_receiver(receivers, watched(work_package), :watched)
add_receiver(receivers, subscribed(journal), :subscribed)
add_receiver(receivers, settings_of_mentioned(journal), :mentioned)
add_receiver(receivers, settings_of_involved(journal), :involved)
add_receiver(receivers, settings_of_watched(work_package), :watched)
add_receiver(receivers, settings_of_subscribed(journal), :subscribed)
receivers
end
def mentioned(journal)
allowed_and_unique(mentioned_ids(journal),
journal.data.project,
:mentioned)
def settings_of_mentioned(journal)
applicable_settings(mentioned_ids(journal),
journal.data.project,
:mentioned)
end
def involved(journal)
def settings_of_involved(journal)
scope = User
.where(id: group_or_user_ids(journal.data.assigned_to))
.or(User.where(id: group_or_user_ids(journal.data.responsible)))
allowed_and_unique(scope,
journal.data.project,
:involved)
applicable_settings(scope,
journal.data.project,
:involved)
end
def subscribed(journal)
def settings_of_subscribed(journal)
project = journal.data.project
allowed_and_unique(User.notified_on_all(project),
project,
:all)
applicable_settings(User.notified_on_all(project),
project,
:all)
end
def watched(work_package)
allowed_and_unique(work_package.watcher_users,
work_package.project,
:watched)
def settings_of_watched(work_package)
applicable_settings(work_package.watcher_users,
work_package.project,
:watched)
end
def allowed_and_unique(user_scope, project, reason)
def applicable_settings(user_scope, project, reason)
NotificationSetting
.applicable(project)
.where(reason => true)
@ -226,6 +222,10 @@ class Notifications::JournalWpNotificationService
association.is_a?(Group) ? association.user_ids : association&.id
end
def user_with_fallback(journal)
journal.user || DeletedUser.first
end
def add_receiver(receivers, collection, reason)
collection.each do |notification|
receivers[notification.user_id][notification.channel] << reason

@ -33,21 +33,9 @@ module Notifications
def set_default_attributes(params)
super
set_default_subject unless model.subject
set_default_project unless model.project
end
def set_default_subject
# TODO: Work package journal specific.
# Extract into strategy per event resource
journable = model.resource
class_name = journable.class.name.underscore
model.subject = I18n.t("notifications.#{class_name.pluralize}.subject.#{model.reason}",
**{ class_name.to_sym => journable.to_s })
end
##
# Try to determine the project context from the journal (if any)
# or the resource if it has a project set

@ -54,8 +54,9 @@ module Users
end
def initialize_notification_settings
model.notification_settings.build(channel: :mail, involved: true, mentioned: true, watched: true)
model.notification_settings.build(channel: :in_app, involved: true, mentioned: true, watched: true)
NotificationSetting.channels.each_key do |channel|
model.notification_settings.build(channel: channel, involved: true, mentioned: true, watched: true)
end
end
end
end

@ -38,11 +38,22 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field"><%= setting_check_box :bcc_recipients %></div>
<div class="form--field"><%= setting_check_box :plain_text_mail %></div>
<div class="form--field">
<%= setting_number_field :notification_email_delay_minutes, container_class: '-xslim', unit: t(:label_minute_plural) %>
<%= setting_number_field :notification_email_delay_minutes,
container_class: '-xslim',
min: 0,
unit: t(:label_minute_plural) %>
<div class="form--field-instructions">
<%= t(:'settings.notifications.delay_minutes_explanation') %>
</div>
</div>
<div class="form--field">
<%= setting_time_field :notification_email_digest_time,
container_class: '-xslim',
unit: 'UTC' %>
<div class="form--field-instructions">
<%= t(:'settings.notifications.email_digest_explanation') %>
</div>
</div>
</section>
<fieldset id="emails_decorators" class="form--fieldset"><legend class="form--fieldset-legend"><%= t(:setting_emails_header) %> & <%= t(:setting_emails_footer) %></legend>

@ -33,7 +33,10 @@ See docs/COPYRIGHT.rdoc for more details.
<%= styled_form_tag(admin_settings_notifications_path, method: :patch) do %>
<div class="form--field">
<%= setting_number_field :journal_aggregation_time_minutes, unit: t(:label_minute_plural), container_class: '-xslim' %>
<%= setting_number_field :journal_aggregation_time_minutes,
unit: t(:label_minute_plural),
min: 0,
container_class: '-xslim' %>
<span class="form--field-instructions">
<%= t(:text_journal_aggregation_time_explanation) %><br/>
<%= t(:text_hint_disable_with_0) %>
@ -41,7 +44,7 @@ See docs/COPYRIGHT.rdoc for more details.
</div>
<div class="form--field">
<%= setting_number_field :notification_retention_period_days, size: 6, min: 1, unit: t(:label_day_plural), container_class: '-xslim' %>
<%= setting_number_field :notification_retention_period_days, size: 6, min: 2, unit: t(:label_day_plural), container_class: '-xslim' %>
<span class="form--field-instructions">
<%= t(:'settings.notifications.retention_text') %>
</span>

@ -0,0 +1,56 @@
<div style="color: #777; font-weight: bold">
<%= digest_timespan_text %>
</div>
<% @notifications_by_project.each do |project, notifications_by_work_package| %>
<section style="margin-bottom: 3em; margin-top: 5em">
<h1 style="font-size: 2em; margin-bottom: 1.5em"><%= link_to_project(project, only_path: false) %></h1>
<% notifications_by_work_package.each do |work_package, notifications| %>
<section style="margin-bottom: 3em;">
<h2 style="margin-bottom: 1em; font-size: 1.5em;"><%= link_to_work_package work_package, only_path: false %></h2>
<% notifications.sort_by(&:created_at).each do |notification| %>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<tr>
<td width="20px"></td>
<td style="font-weight: normal; font-size: 1.1em;">
<%= digest_notification_timestamp_text(notification) %>
</td>
<td style="text-align: right">
<%= I18n.t(:"mail.digests.work_packages.reason.#{notification.reason_mail_digest}") %>
</td>
<td width="20px"></td>
</tr>
</table>
<% journal = notification.journal %>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<tr>
<td width="20px"></td>
<td>
<%= format_text(journal.notes,
only_path: false,
object: notification.resource,
project: notification.project) %>
<% if journal.notes.present? && journal.details.any? %>
<div style="margin-bottom: 2em"></div>
<% end %>
<ul>
<% journal.details.each do |detail| %>
<li><%= journal.render_detail(detail, only_path: false) %></li>
<% end %>
</ul>
</td>
</tr>
</table>
<div style="margin-bottom: 3em"></div>
<% end %>
</section>
<% end %>
</section>
<% end %>

@ -0,0 +1,36 @@
<%= digest_timespan_text %>
<% @notifications_by_project.each do |project, notifications_by_work_package| %>
<%= "=" * (project.name.length + 4) %>
= <%= project.name %> =
<%= "=" * (project.name.length + 4) %>
<% notifications_by_work_package.each do |work_package, notifications| %>
<%= "*" * (work_package.to_s.length + 4) %>
* <%= work_package.to_s %> *
<%= "*" * (work_package.to_s.length + 4) %>
<% notifications.sort_by(&:created_at).each do |notification| %>
<%= "-" * 20 %>
<%= digest_notification_timestamp_text(notification, html: false) %> (<%= I18n.t('mail.digests.work_packages.reason.prefix',
reason: I18n.t(:"mail.digests.work_packages.reason.#{notification.reason_mail_digest}")) %>)
<% journal = notification.journal %>
<% if journal.notes.present? %>
<%= journal.notes %>
<% end %>
<% journal.details.each do |detail| %>
* <%= journal.render_detail(detail, only_path: false, no_html: true) %>
<% end %>
<% end %>
<%= "-" * 20 %>
<% end %>
<% end %>

@ -31,35 +31,40 @@
class Mails::DeliverJob < ApplicationJob
queue_with_priority :notification
def perform(recipient_id, sender_id)
@recipient_id = recipient_id
@sender_id = sender_id
def perform(recipient_id)
self.recipient_id = recipient_id
return if abort?
mail = User.execute_as(recipient) { build_mail }
mail&.deliver_now
deliver_mail
end
private
attr_accessor :recipient_id
def abort?
# nothing to do if recipient was deleted in the meantime
recipient.nil?
end
def deliver_mail
mail = User.execute_as(recipient) { build_mail }
mail&.deliver_now
end
# To be implemented by subclasses.
# Actual recipient and sender User objects are passed (always non-nil).
# Returns a Mail::Message, or nil if no message should be sent.
# rubocop:disable Lint/UnusedMethodArgument
def render_mail(recipient:, sender:)
raise 'SubclassResponsibility'
def render_mail
raise NotImplementedError, 'SubclassResponsibility'
end
# rubocop:enable Lint/UnusedMethodArgument
def build_mail
render_mail(recipient: recipient, sender: sender)
render_mail
rescue NotImplementedError
# Notify subclass of the need to implement
raise
rescue StandardError => e
Rails.logger.error "#{self.class.name}: Unexpected error rendering a mail: #{e}"
# not raising, to avoid re-schedule of DelayedJob; don't expect render errors to fix themselves
@ -68,18 +73,10 @@ class Mails::DeliverJob < ApplicationJob
end
def recipient
@recipient ||= if @recipient_id.is_a?(User)
@recipient_id
@recipient ||= if recipient_id.is_a?(User)
recipient_id
else
User.find_by(id: @recipient_id)
User.find_by(id: recipient_id)
end
end
def sender
@sender ||= if @sender_id.is_a?(User)
@sender_id
else
User.find_by(id: @sender_id) || DeletedUser.first
end
end
end

@ -0,0 +1,83 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::DigestJob < Mails::DeliverJob
class << self
def execution_time(user)
zone = (user.time_zone || ActiveSupport::TimeZone.new('UTC'))
zone.parse(Setting.notification_email_digest_time) + 1.day
end
end
private
def render_mail
# Have to cast to array since the update in the subsequent block
# will result in the notification to not be found via the .mail_digest_before scope.
notification_ids = Notification.mail_digest_before(recipient: recipient, time: Time.current).pluck(:id)
return nil if notification_ids.empty?
with_marked_notifications(notification_ids) do
DigestMailer
.work_packages(recipient.id, notification_ids)
end
end
# Running the digest job will take some time to complete.
# Within this timeframe, new notifications might come in. Upon notification creation
# a job is scheduled unless there is no prior digest notification that is not yet read (read_mail_digest: true).
# If we were to only set the read_mail_digest state at the end of the mail rendering an edge case of the following
# would lead to digest not being sent or at least sent unduly late:
# * Job starts and fetches the notifications for rendering. We need to fetch all notifications to be rendered to
# order them as desired.
# * Notification is created. Because there are unhandled digest notifications no job is scheduled.
# * The above can happen repeatedly.
# * Job ends.
# * No new notification is generated.
#
# A new job would then only be scheduled upon the creation of a new digest notification which (as unlikely as that is)
# might only happen after some days have gone by.
#
# Because we mark the notifications as read even though they in fact aren't, we do it in a transaction
# so that the change is rolled back in case of an error.
def with_marked_notifications(notification_ids)
Notification.transaction do
mark_notifications_read(notification_ids)
yield
end
end
def mark_notifications_read(notification_ids)
Notification.where(id: notification_ids).update_all(read_mail_digest: true, updated_at: Time.current)
end
end

@ -29,13 +29,15 @@
#++
class Mails::WatcherJob < Mails::DeliverJob
include Mails::WithSender
def perform(watcher, watcher_changer)
self.watcher = watcher
super(watcher.user, watcher_changer)
end
def render_mail(recipient:, sender:)
def render_mail
UserMailer
.work_package_watcher_changed(watcher.watchable,
recipient,

@ -0,0 +1,49 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Mails::WithSender
def perform(recipient_id, sender_id)
self.sender_id = sender_id
super(recipient_id)
end
private
attr_accessor :sender_id
def sender
@sender ||= if sender_id.is_a?(User)
sender_id
else
User.find_by(id: sender_id) || DeletedUser.first
end
end
end

@ -29,14 +29,14 @@
#++
class Mails::WorkPackageJob < Mails::DeliverJob
queue_with_priority :notification
include Mails::WithSender
def perform(journal_id, recipient_id, author_id)
@journal_id = journal_id
super(recipient_id, author_id)
end
def render_mail(recipient:, sender:)
def render_mail
return nil unless journal # abort, assuming that the underlying WP was deleted
if journal.initial?

@ -606,7 +606,7 @@ de:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_email:
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
parse_schema_filter_params_service:
attributes:

@ -675,8 +675,16 @@ en:
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_email:
read_mail:
read_on_creation: 'cannot be set to true on notification creation.'
read_mail_digest:
read_on_creation: 'cannot be set to true on notification creation.'
reason_ian:
no_notification_reason: 'cannot be blank as IAN is chosen as a channel.'
reason_mail:
no_notification_reason: 'cannot be blank as mail is chosen as a channel.'
reason_mail_digest:
no_notification_reason: 'cannot be blank as mail digest is chosen as a channel.'
parse_schema_filter_params_service:
attributes:
base:
@ -1950,6 +1958,18 @@ en:
mail:
actions: 'Actions'
digests:
time_frame: 'Summary of all events you subscribed to in the period between %{start} and %{end}'
work_packages:
created_at: '%{user} created at %{timestamp}'
reason:
watched: 'watched'
involved: 'assignee or responsible'
mentioned: 'mentioned'
subscribed: 'all'
prefix: 'Received because of the notification setting: %{reason}'
subject: 'Daily project summary for %{date} - %{number} updates'
updated_at: '%{user} updated at %{timestamp}'
mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
mail_body_account_information: "Your account information"
@ -2405,6 +2425,7 @@ en:
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_delay_minutes: "Email sending delay"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Enable Feeds"
setting_feeds_limit: "Feed content limit"
setting_file_max_size_displayed: "Max size of text files displayed inline"
@ -2487,6 +2508,7 @@ en:
Set the number of days notification events for users (the source for in-app notifications)
will be kept in the system. Any events older than this time will be deleted.
delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification."
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set,

@ -564,6 +564,7 @@ en:
channels:
in_app: "In-app"
mail: "Email"
mail_digest: "Daily email summary"
reasons:
mentioned: 'mentioned'
watched: 'watched'

@ -128,6 +128,9 @@ software_url:
attachment_max_size:
format: int
default: 5120
attachment_whitelist:
serialized: true
default: []
work_packages_export_limit:
format: int
default: 500
@ -388,6 +391,5 @@ notification_retention_period_days:
notification_email_delay_minutes:
default: 15
format: int
attachment_whitelist:
serialized: true
default: []
notification_email_digest_time:
default: '8:00'

@ -89,14 +89,18 @@ class AddNotificationSettings < ActiveRecord::Migration[6.1]
(user_id,
channel,
involved,
mentioned)
mentioned,
watched)
SELECT
id,
0,
true,
true,
true
FROM
users
WHERE
type = 'User'
SQL
end

@ -0,0 +1,43 @@
class AddDigestSetting < ActiveRecord::Migration[6.1]
def up
insert_default_digest_channel
end
def down
remove_digest_channels
end
def insert_default_digest_channel
execute <<~SQL
INSERT INTO
notification_settings
(user_id,
channel,
involved,
mentioned,
watched)
SELECT
id,
2,
true,
true,
true
FROM
users
WHERE
type = 'User'
SQL
end
# Removes all digest channels. Includes non default channels as those might
# also have been added not by the migration but in the cause of the functionality
# the migration was added for.
def remove_digest_channels
execute <<~SQL
DELETE FROM
notification_settings
WHERE
channel = 2
SQL
end
end

@ -0,0 +1,12 @@
class AddDigestToNotification < ActiveRecord::Migration[6.1]
def change
change_table :notifications, bulk: true do |t|
t.column :read_mail_digest, :boolean, default: false, index: true
t.column :reason_mail, :integer, limit: 1
t.column :reason_mail_digest, :integer, limit: 1
end
rename_column :notifications, :reason, :reason_ian
rename_column :notifications, :read_email, :read_mail
end
end

@ -1,6 +1,6 @@
<td
*ngIf="first"
rowspan="2"
rowspan="3"
>
<span
*ngIf="setting._links.project.href; else defaultTitle"
@ -53,7 +53,7 @@
</td>
<td
*ngIf="first"
rowspan="2"
rowspan="3"
class="buttons"
>
<button

@ -25,12 +25,12 @@ export class NotificationSettingRowComponent implements OnInit {
email: this.I18n.t('js.notifications.email'),
inApp: this.I18n.t('js.notifications.in_app'),
remove_all: this.I18n.t('js.notifications.settings.remove_all'),
involved_header: 'I am involved',
mentioned_header: 'I was mentioned',
watched_header: 'I am watching',
any_event_header: 'All events',
default_all_projects: 'Default for all projects',
add_setting: 'Add settings for project',
involved_header: this.I18n.t('js.notifications.settings.involved'),
mentioned_header: this.I18n.t('js.notifications.settings.mentioned'),
watched_header: this.I18n.t('js.notifications.settings.watched'),
any_event_header: this.I18n.t('js.notifications.settings.all'),
default_all_projects: this.I18n.t('js.notifications.settings.default_all_projects'),
add_setting: this.I18n.t('js.notifications.settings.add'),
channel: (channel:string):string => this.I18n.t(`js.notifications.channels.${channel}`),
};

@ -30,8 +30,6 @@ export class NotificationSettingsTableComponent {
text = {
save: this.I18n.t('js.button_save'),
email: this.I18n.t('js.notifications.email'),
inApp: this.I18n.t('js.notifications.in_app'),
involved_header: this.I18n.t('js.notifications.settings.involved'),
channel_header: this.I18n.t('js.notifications.channel'),
mentioned_header: this.I18n.t('js.notifications.settings.mentioned'),
@ -64,6 +62,7 @@ export class NotificationSettingsTableComponent {
const added:NotificationSetting[] = [
buildNotificationSetting(project, { channel: 'in_app' }),
buildNotificationSetting(project, { channel: 'mail' }),
buildNotificationSetting(project, { channel: 'mail_digest' }),
];
this.store.update(

@ -1,6 +1,6 @@
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
export type NotificationSettingChannel = 'mail'|'in_app';
export type NotificationSettingChannel = 'mail'|'mail_digest'|'in_app';
export interface NotificationSetting {
_links:{ project:HalSourceLink };
@ -21,7 +21,7 @@ export function buildNotificationSetting(project:null|HalSourceLink, params:Part
},
involved: true,
mentioned: true,
watched: false,
watched: true,
all: false,
channel: 'in_app',
...params,

@ -536,7 +536,7 @@ fieldset.form--fieldset
.form--text-field
@extend %input-style
&.-number
&.-number, &.-time
text-align: right
.form--text-field,

@ -45,9 +45,9 @@ module API
property :read_ian,
as: :readIAN
property :read_email
property :reason
property :reason_ian,
as: :reason
date_time_property :created_at
@ -81,24 +81,6 @@ module API
}
end
link :readEmail do
next if represented.read_email
{
href: api_v3_paths.notification_read_email(represented.id),
method: :post
}
end
link :unreadEmail do
next unless represented.read_email
{
href: api_v3_paths.notification_unread_email(represented.id),
method: :post
}
end
associated_resource :actor,
representer: ::API::V3::Users::UserRepresenter,
skip_render: ->(*) { represented.actor.nil? },

@ -64,14 +64,6 @@ module API
bulk_update_status(read_ian: false)
end
post :read_email do
bulk_update_status(read_email: true)
end
post :unread_email do
bulk_update_status(read_email: false)
end
route_param :id, type: Integer, desc: 'Notification ID' do
after_validation do
@notification = Notification.recipient(current_user).find(params[:id])
@ -93,14 +85,6 @@ module API
post :unread_ian do
update_status(read_ian: false)
end
post :read_email do
update_status(read_email: true)
end
post :unread_email do
update_status(read_email: false)
end
end
end
end

@ -229,14 +229,6 @@ module API
"#{notifications}/unread_ian"
end
def self.notification_bulk_read_email
"#{notifications}/read_email"
end
def self.notification_bulk_unread_email
"#{notifications}/unread_email"
end
def self.notification_read_ian(id)
"#{notification(id)}/read_ian"
end
@ -245,14 +237,6 @@ module API
"#{notification(id)}/unread_ian"
end
def self.notification_read_email(id)
"#{notification(id)}/read_email"
end
def self.notification_unread_email(id)
"#{notification(id)}/unread_email"
end
index :placeholder_user
show :placeholder_user

@ -32,7 +32,7 @@ module OpenProject
module FormTagHelper
include ActionView::Helpers::FormTagHelper
TEXT_LIKE_FIELDS = %w(number_field password_field url_field telephone_field email_field).freeze
TEXT_LIKE_FIELDS = %w(number_field password_field url_field telephone_field email_field time_field).freeze
def styled_form_tag(url_for_options = {}, options = {}, &block)
apply_css_class_to_options(options, 'form')

@ -117,19 +117,24 @@ module Redmine
/(\[(.+?)\]\((.+?)\))/
end
# format the time in the user time zone if one is set
# if none is set and the time is in utc time zone (meaning it came from active record), format the date in the system timezone
# otherwise just use the date in the time zone attached to the time
def format_time_as_date(time, format)
# Format the time to a date in the user time zone if one is set.
# If none is set and the time is in utc time zone (meaning it came from active record), format the date in the system timezone
# otherwise just use the date in the time zone attached to the time.
def format_time_as_date(time, format = nil)
return nil unless time
zone = User.current.time_zone
local_date = (if zone
time.in_time_zone(zone)
else
(time.utc? ? time.localtime : time)
time.utc? ? time.localtime : time
end).to_date
local_date.strftime(format)
if format
local_date.strftime(format)
else
format_date(local_date)
end
end
def format_time(time, include_date = true)

@ -37,70 +37,120 @@ describe Notifications::CreateContract do
end
end
let(:event_context) { FactoryBot.build_stubbed(:project) }
let(:event_resource) { FactoryBot.build_stubbed(:journal) }
let(:event_recipient) { FactoryBot.build_stubbed(:user) }
let(:event_subject) { 'Some text' }
let(:event_reason) { :mentioned }
let(:event_read_ian) { false }
let(:event_read_email) { false }
let(:event) do
Notification.new(project: event_context,
recipient: event_recipient,
subject: event_subject,
reason: event_reason,
resource: event_resource,
read_ian: event_read_ian,
read_email: event_read_email)
let(:notification_context) { FactoryBot.build_stubbed(:project) }
let(:notification_resource) { FactoryBot.build_stubbed(:journal) }
let(:notification_recipient) { FactoryBot.build_stubbed(:user) }
let(:notification_subject) { 'Some text' }
let(:notification_reason_ian) { :mentioned }
let(:notification_reason_mail) { :involved }
let(:notification_reason_mail_digest) { :watched }
let(:notification_read_ian) { false }
let(:notification_read_mail) { false }
let(:notification_read_mail_digest) { false }
let(:notification) do
Notification.new(project: notification_context,
recipient: notification_recipient,
subject: notification_subject,
reason_ian: notification_reason_ian,
reason_mail: notification_reason_mail,
reason_mail_digest: notification_reason_mail_digest,
resource: notification_resource,
read_ian: notification_read_ian,
read_mail: notification_read_mail,
read_mail_digest: notification_read_mail_digest)
end
let(:contract) { described_class.new(event, current_user) }
let(:contract) { described_class.new(notification, current_user) }
describe '#validation' do
it_behaves_like 'contract is valid'
context 'without a recipient' do
let(:event_recipient) { nil }
let(:notification_recipient) { nil }
it_behaves_like 'contract is invalid', recipient: :blank
end
context 'without a reason' do
let(:event_reason) { nil }
context 'without a reason for IAN with read_ian false' do
let(:notification_reason_ian) { nil }
let(:notification_read_ian) { false }
it_behaves_like 'contract is invalid', reason: :blank
it_behaves_like 'contract is invalid', reason_ian: :no_notification_reason
end
context 'without a reason for IAN with read_ian nil' do
let(:notification_reason_ian) { nil }
let(:notification_read_ian) { nil }
it_behaves_like 'contract is valid'
end
context 'without a reason for mail with read_mail false' do
let(:notification_reason_mail) { nil }
let(:notification_read_mail) { false }
it_behaves_like 'contract is invalid', reason_mail: :no_notification_reason
end
context 'without a reason for mail with read_mail nil' do
let(:notification_reason_mail) { nil }
let(:notification_read_mail) { nil }
it_behaves_like 'contract is valid'
end
context 'without a reason for mail_digest with read_mail_digest false' do
let(:notification_reason_mail_digest) { nil }
let(:notification_read_mail_digest) { false }
it_behaves_like 'contract is invalid', reason_mail_digest: :no_notification_reason
end
context 'without a reason for mail_digest with read_mail_digest nil' do
let(:notification_reason_mail_digest) { nil }
let(:notification_read_mail_digest) { nil }
it_behaves_like 'contract is valid'
end
context 'without a subject' do
let(:event_subject) { nil }
let(:notification_subject) { nil }
it_behaves_like 'contract is invalid', subject: :blank
it_behaves_like 'contract is valid'
end
context 'with an empty subject' do
let(:event_subject) { '' }
let(:notification_subject) { '' }
it_behaves_like 'contract is invalid', subject: :blank
it_behaves_like 'contract is valid'
end
context 'with all channels nil' do
let(:event_read_ian) { nil }
let(:event_read_email) { nil }
let(:notification_read_ian) { nil }
let(:notification_read_mail) { nil }
let(:notification_read_mail_digest) { nil }
it_behaves_like 'contract is invalid', base: :at_least_one_channel
end
context 'with read_ian true' do
let(:event_read_ian) { true }
let(:notification_read_ian) { true }
it_behaves_like 'contract is invalid', read_ian: :read_on_creation
end
context 'with read_email true' do
let(:event_read_email) { true }
context 'with read_mail true' do
let(:notification_read_mail) { true }
it_behaves_like 'contract is invalid', read_mail: :read_on_creation
end
context 'with read_mail_digest true' do
let(:notification_read_mail_digest) { true }
it_behaves_like 'contract is invalid', read_email: :read_on_creation
it_behaves_like 'contract is invalid', read_mail_digest: :read_on_creation
end
end
end

@ -2,8 +2,11 @@ FactoryBot.define do
factory :notification do
subject { "MyText" }
read_ian { false }
read_email { false }
reason { :mentioned }
read_mail { false }
read_mail_digest { false }
reason_ian { :mentioned }
reason_mail { :involved}
reason_mail_digest { :watched }
recipient factory: :user
project { association :project }
resource { association :work_package, project: project }

@ -12,6 +12,10 @@ FactoryBot.define do
channel { :mail }
end
factory :mail_digest_notification_setting do
channel { :mail_digest }
end
factory :in_app_notification_setting do
channel { :in_app }
end

@ -60,7 +60,8 @@ FactoryBot.define do
if user.notification_settings.empty?
user.notification_settings = [
FactoryBot.create(:mail_notification_setting, user: user, all: true),
FactoryBot.create(:in_app_notification_setting, user: user, all: true)
FactoryBot.create(:in_app_notification_setting, user: user, all: true),
FactoryBot.create(:mail_digest_notification_setting, user: user),
]
end

@ -0,0 +1,109 @@
require 'spec_helper'
require 'support/pages/my/notifications'
describe "Digest email", type: :feature, js: true do
let!(:project) { FactoryBot.create :project, members: { current_user => role } }
let!(:mute_project) { FactoryBot.create :project, members: { current_user => role } }
let(:notification_settings_page) { Pages::My::Notifications.new(current_user) }
let(:role) { FactoryBot.create(:role, permissions: %i[view_work_packages]) }
let(:other_user) { FactoryBot.create(:user) }
let(:work_package) { FactoryBot.create(:work_package, project: project) }
let(:watched_work_package) { FactoryBot.create(:work_package, project: project, watcher_users: [current_user]) }
let(:involved_work_package) { FactoryBot.create(:work_package, project: project, assigned_to: current_user) }
current_user do
FactoryBot.create :user,
notification_settings: [
FactoryBot.build(:mail_notification_setting,
involved: false,
watched: false,
mentioned: false,
all: false),
FactoryBot.build(:in_app_notification_setting,
involved: false,
watched: false,
mentioned: false,
all: false),
FactoryBot.build(:mail_digest_notification_setting,
involved: true,
watched: true,
mentioned: true,
all: false)
]
end
before do
watched_work_package
work_package
involved_work_package
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end
it 'sends a digest mail based on the configuration', with_settings: { journal_aggregation_time_minutes: 0 } do
# Configure the digest
notification_settings_page.visit!
notification_settings_page.expect_setting channel: :mail_digest,
project: nil,
involved: true,
mentioned: true,
watched: true,
all: false
notification_settings_page.configure_channel :mail_digest,
project: nil,
involved: false,
mentioned: true,
watched: true,
all: false
notification_settings_page.add_row(mute_project)
notification_settings_page.configure_channel :mail_digest,
project: mute_project,
involved: false,
mentioned: false,
watched: false,
all: false
notification_settings_page.save
# Perform some actions the user listens to
User.execute_as other_user do
note = <<~NOTE
Hey <mention class=\"mention\"
data-id=\"#{current_user.id}\"
data-type=\"user\"
data-text=\"@#{current_user.name}\">
@#{current_user.name}
</mention>
NOTE
work_package.add_journal(other_user, note)
work_package.save!
watched_work_package.subject = 'New watched work package subject'
watched_work_package.save!
involved_work_package.description = 'New involved work package description'
involved_work_package.save!
end
# Have to explicitly execute the delayed jobs. If we were to execute all
# by wrapping the above, work package altering code, inside a
# perform_enqueued_jobs block, the digest job would be executed right away
# so that the second update would trigger a new digest. But we want to test
# that only one digest is sent out
perform_enqueued_jobs
perform_enqueued_jobs
expect(ActionMailer::Base.deliveries.length)
.to eql 1
expect(ActionMailer::Base.deliveries.first.subject)
.to eql I18n.t(:'mail.digests.work_packages.subject',
date: Time.current.strftime('%m/%d/%Y'),
number: 2)
end
end

@ -1,11 +1,14 @@
require 'rails_helper'
require_relative '../users/notifications/shared_examples'
require 'support/pages/my/notifications'
describe "My notifications settings", type: :feature, js: true do
current_user { FactoryBot.create :user }
let(:settings_page) { Pages::My::Notifications.new(current_user) }
before do
visit my_notifications_path
settings_page.visit!
end
it_behaves_like 'notification settings workflow' do

@ -4,8 +4,6 @@ shared_examples 'notification settings workflow' do
let!(:role) { FactoryBot.create :role, permissions: %i[view_project] }
let!(:member) { FactoryBot.create :member, user: user, project: project, roles: [role] }
let(:settings_page) { ::Pages::Notifications::Settings.new(user) }
it 'allows to control notification settings' do
# Expect default settings
settings_page.expect_represented
@ -29,12 +27,20 @@ shared_examples 'notification settings workflow' do
watched: false,
all: false
# Set settings for project email digest
settings_page.configure_channel :mail_digest,
project: project,
involved: false,
mentioned: true,
watched: false,
all: true
settings_page.save
user.reload
notification_settings = user.notification_settings
expect(notification_settings.count).to eq 4
expect(notification_settings.where(project: project).count).to eq 2
expect(notification_settings.count).to eq 6
expect(notification_settings.where(project: project).count).to eq 3
in_app = notification_settings.find_by(project: project, channel: :in_app)
expect(in_app.involved).to be_truthy
@ -47,6 +53,12 @@ shared_examples 'notification settings workflow' do
expect(in_app.mentioned).to be_truthy
expect(in_app.watched).to be_truthy
expect(in_app.all).to be_falsey
in_app = notification_settings.find_by(project: project, channel: :mail_digest)
expect(in_app.involved).to be_falsey
expect(in_app.mentioned).to be_truthy
expect(in_app.watched).to be_falsey
expect(in_app.all).to be_truthy
end
end
end

@ -4,9 +4,11 @@ require_relative './shared_examples'
describe "user notifications settings", type: :feature, js: true do
shared_let(:user) { FactoryBot.create :user }
let(:settings_page) { ::Pages::Notifications::Settings.new(user) }
before do
login_as current_user
visit edit_user_path(user, tab: :notifications)
settings_page.visit!
end
context 'as admin' do

@ -38,7 +38,7 @@ describe SettingsHelper, type: :helper do
describe '#setting_select' do
before do
expect(Setting).to receive(:field).and_return('2')
allow(Setting).to receive(:field).and_return('2')
end
subject(:output) do
@ -57,7 +57,7 @@ describe SettingsHelper, type: :helper do
describe '#setting_multiselect' do
before do
expect(Setting).to receive(:field).at_least(:once).and_return('1')
allow(Setting).to receive(:field).at_least(:once).and_return('1')
end
subject(:output) do
@ -83,8 +83,8 @@ describe SettingsHelper, type: :helper do
describe '#settings_matrix' do
before do
expect(Setting).to receive(:field_a).at_least(:once).and_return('2')
expect(Setting).to receive(:field_b).at_least(:once).and_return('3')
allow(Setting).to receive(:field_a).at_least(:once).and_return('2')
allow(Setting).to receive(:field_b).at_least(:once).and_return('3')
end
subject(:output) do
@ -157,7 +157,7 @@ describe SettingsHelper, type: :helper do
describe '#setting_text_field' do
before do
expect(Setting).to receive(:field).and_return('important value')
allow(Setting).to receive(:field).and_return('important value')
end
subject(:output) do
@ -178,7 +178,7 @@ describe SettingsHelper, type: :helper do
describe '#setting_text_area' do
before do
expect(Setting).to receive(:field).and_return('important text')
allow(Setting).to receive(:field).and_return('important text')
end
subject(:output) do
@ -204,7 +204,7 @@ important text</textarea>
context 'when setting is true' do
before do
expect(Setting).to receive(:field?).and_return(true)
allow(Setting).to receive(:field?).and_return(true)
end
it_behaves_like 'labelled by default'
@ -219,7 +219,7 @@ important text</textarea>
context 'when setting is false' do
before do
expect(Setting).to receive(:field?).and_return(false)
allow(Setting).to receive(:field?).and_return(false)
end
it_behaves_like 'labelled by default'
@ -233,6 +233,27 @@ important text</textarea>
end
end
describe '#setting_time_field' do
subject(:output) do
helper.setting_time_field :field, options
end
before do
allow(Setting).to receive(:field).and_return('16:00')
end
it_behaves_like 'labelled by default'
it_behaves_like 'wrapped in field-container by default'
it_behaves_like 'wrapped in container', 'text-field-container'
it 'should output element' do
expect(output).to be_html_eql(%{
<input class="custom-class form--text-field -time"
id="settings_field" name="settings[field]" type="time" value="16:00" />
}).at_path('input')
end
end
describe '#setting_label' do
subject(:output) do
helper.setting_label :field
@ -244,7 +265,7 @@ important text</textarea>
describe '#notification_field' do
before do
expect(Setting).to receive(:notified_events).and_return(%w(interesting_stuff))
allow(Setting).to receive(:notified_events).and_return(%w(interesting_stuff))
end
subject(:output) do

@ -44,8 +44,7 @@ describe ::API::V3::Notifications::NotificationRepresenter, 'rendering' do
resource: resource,
journal: journal,
actor: actor,
read_ian: read_ian,
read_email: read_email
read_ian: read_ian
end
let(:representer) do
described_class.create notification,
@ -55,7 +54,6 @@ describe ::API::V3::Notifications::NotificationRepresenter, 'rendering' do
let(:embed_links) { false }
let(:read_ian) { false }
let(:read_email) { false }
subject(:generated) { representer.to_json }
@ -95,34 +93,6 @@ describe ::API::V3::Notifications::NotificationRepresenter, 'rendering' do
end
end
describe 'Email read and unread links' do
context 'when unread' do
it_behaves_like 'has an untitled link' do
let(:link) { 'readEmail' }
let(:href) { api_v3_paths.notification_read_email notification.id }
let(:method) { :post }
end
it_behaves_like 'has no link' do
let(:link) { 'unreadEmail' }
end
end
context 'when read' do
let(:read_email) { true }
it_behaves_like 'has an untitled link' do
let(:link) { 'unreadEmail' }
let(:href) { api_v3_paths.notification_unread_email notification.id }
let(:method) { :post }
end
it_behaves_like 'has no link' do
let(:link) { 'readEmail' }
end
end
end
describe 'properties' do
it_behaves_like 'property', :_type do
let(:value) { 'Notification' }
@ -137,7 +107,7 @@ describe ::API::V3::Notifications::NotificationRepresenter, 'rendering' do
end
it_behaves_like 'property', :reason do
let(:value) { notification.reason }
let(:value) { notification.reason_ian }
end
it_behaves_like 'datetime property', :createdAt do

@ -234,18 +234,6 @@ describe ::API::V3::Utilities::PathHelper do
it_behaves_like 'api v3 path', '/notifications/unread_ian'
end
describe '#notification_bulk_read_email' do
subject { helper.notification_bulk_read_email }
it_behaves_like 'api v3 path', '/notifications/read_email'
end
describe '#notification_bulk_unread_email' do
subject { helper.notification_bulk_unread_email }
it_behaves_like 'api v3 path', '/notifications/unread_email'
end
describe '#notification_read_ian' do
subject { helper.notification_read_ian(42) }
@ -257,18 +245,6 @@ describe ::API::V3::Utilities::PathHelper do
it_behaves_like 'api v3 path', '/notifications/42/unread_ian'
end
describe '#notification_read_email' do
subject { helper.notification_read_email(42) }
it_behaves_like 'api v3 path', '/notifications/42/read_email'
end
describe '#notification_unread_email' do
subject { helper.notification_unread_email(42) }
it_behaves_like 'api v3 path', '/notifications/42/unread_email'
end
end
describe 'markup paths' do

@ -0,0 +1,121 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe DigestMailer, type: :mailer do
include OpenProject::ObjectLinking
include ActionView::Helpers::UrlHelper
include OpenProject::StaticRouting::UrlHelpers
include Redmine::I18n
let(:recipient) do
FactoryBot.build_stubbed(:user).tap do |u|
allow(User)
.to receive(:find)
.with(u.id)
.and_return(u)
end
end
let(:project1) { FactoryBot.build_stubbed(:project) }
let(:work_package) do
FactoryBot.build_stubbed(:work_package,
type: FactoryBot.build_stubbed(:type))
end
let(:journal) do
FactoryBot.build_stubbed(:work_package_journal,
notes: 'Some notes').tap do |j|
allow(j)
.to receive(:details)
.and_return({ "subject" => ["old subject", "new subject"] })
end
end
let(:notifications) do
[FactoryBot.build_stubbed(:notification,
resource: work_package,
journal: journal,
project: project1)].tap do |notifications|
allow(Notification)
.to receive(:where)
.and_return(notifications)
allow(notifications)
.to receive(:includes)
.and_return(notifications)
end
end
describe '#work_packages' do
subject(:mail) { described_class.work_packages(recipient.id, notifications.map(&:id)) }
let(:mail_body) { mail.body.parts.detect { |part| part['Content-Type'].value == 'text/html' }.body.to_s }
it 'notes the day and the number of notifications in the subject' do
expect(mail.subject)
.to eql I18n.t('mail.digests.work_packages.subject',
date: format_time_as_date(Time.current),
number: 1)
end
it 'sends to the recipient' do
expect(mail.to)
.to match_array [recipient.mail]
end
it 'sets the expected message_id header' do
allow(Time)
.to receive(:current)
.and_return(Time.current)
expect(mail['Message-ID']&.value)
.to eql "<openproject.digest-#{recipient.id}-#{Time.current.strftime('%Y%m%d%H%M%S')}@example.net>"
end
it 'sets the expected openproject headers' do
expect(mail['X-OpenProject-User']&.value)
.to eql recipient.name
end
it 'includes the notifications grouped by project and work package' do
expect(mail_body)
.to have_selector('body section h1', text: project1.name)
expect(mail_body)
.to have_selector('body section section h2', text: work_package.to_s)
expect(mail_body)
.to have_selector('body section section p.op-uc-p', text: journal.notes)
expect(mail_body)
.to have_selector('body section section li',
text: "Subject changed from old subject to new subject")
end
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH

@ -0,0 +1,92 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe Notifications::Scopes::MailDigestBefore, type: :model do
describe '.mail_digest_before' do
subject(:scope) { ::Notification.mail_digest_before(recipient: recipient, time: time) }
let(:recipient) do
FactoryBot.create(:user)
end
let(:time) do
Time.current
end
let(:notification) do
FactoryBot.create(:notification,
recipient: notification_recipient,
read_mail_digest: notification_read_mail_digest,
created_at: notification_created_at)
end
let(:notification_read_mail_digest) { false }
let(:notification_created_at) { Time.current - 10.minutes }
let(:notification_recipient) { recipient }
let!(:notifications) { notification }
shared_examples_for 'is empty' do
it 'is empty' do
expect(scope)
.to be_empty
end
end
context 'with a notification of the user for mail digests before the time' do
it 'returns the notification' do
expect(scope)
.to match_array([notification])
end
end
context 'with a notification of the user for mail digests after the time' do
let(:notification_created_at) { Time.current + 10.minutes }
it_behaves_like 'is empty'
end
context 'with a notification of a different user for mail digests before the time' do
let(:notification_recipient) { FactoryBot.create(:user) }
it_behaves_like 'is empty'
end
context 'with a notification of a different user not for mail digests before the time' do
let(:notification_read_mail_digest) { nil }
it_behaves_like 'is empty'
end
context 'with a notification of a different user for already covered mail digests before the time' do
let(:notification_read_mail_digest) { true }
it_behaves_like 'is empty'
end
end
end

@ -128,7 +128,7 @@ describe Queries::Notifications::NotificationQuery, type: :model do
describe '#results' do
it 'is the same as handwriting the query' do
expected = "SELECT \"notifications\".* FROM \"notifications\" WHERE \"notifications\".\"recipient_id\" = #{recipient.id} ORDER BY \"notifications\".\"reason\" DESC, \"notifications\".\"id\" DESC"
expected = "SELECT \"notifications\".* FROM \"notifications\" WHERE \"notifications\".\"recipient_id\" = #{recipient.id} ORDER BY \"reason\" DESC, \"notifications\".\"id\" DESC"
expect(instance.results.to_sql).to eql expected
end

@ -1,116 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
require 'spec_helper'
describe ::API::V3::Notifications::NotificationsAPI,
'bulk set read status',
type: :request,
content_type: :json do
include API::V3::Utilities::PathHelper
shared_let(:recipient) { FactoryBot.create :user }
shared_let(:other_recipient) { FactoryBot.create :user }
shared_let(:notification1) { FactoryBot.create :notification, recipient: recipient }
shared_let(:notification2) { FactoryBot.create :notification, recipient: recipient }
shared_let(:notification3) { FactoryBot.create :notification, recipient: recipient }
shared_let(:other_user_notification) { FactoryBot.create :notification, recipient: other_recipient }
let(:filters) { nil }
let(:read_path) do
api_v3_paths.path_for :notification_bulk_read_email, filters: filters
end
let(:parsed_response) { JSON.parse(last_response.body) }
before do
login_as current_user
post read_path
end
describe 'POST /api/v3/notifications/read_email' do
let(:current_user) { recipient }
it 'returns 204' do
expect(last_response.status)
.to eql(204)
end
it 'sets all the current users`s notifications to read' do
expect(::Notification.where(id: [notification1.id, notification2.id, notification3.id]).pluck(:read_email))
.to all(be_truthy)
expect(::Notification.where(id: [other_user_notification]).pluck(:read_email))
.to all(be_falsey)
end
context 'with a filter for id' do
let(:filters) do
[
{
'id' => {
'operator' => '=',
'values' => [notification1.id.to_s, notification2.id.to_s]
}
}
]
end
it 'sets the current users`s notifications matching the filter to read' do
expect(::Notification.where(id: [notification1.id, notification2.id]).pluck(:read_email))
.to all(be_truthy)
expect(::Notification.where(id: [other_user_notification, notification3.id]).pluck(:read_email))
.to all(be_falsey)
end
end
context 'with an invalid filter' do
let(:filters) do
[
{
'bogus' => {
'operator' => '=',
'values' => []
}
}
]
end
it 'returns 400' do
expect(last_response.status)
.to eql(400)
end
end
end
end

@ -1,116 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
require 'spec_helper'
describe ::API::V3::Notifications::NotificationsAPI,
'bulk unset read status',
type: :request,
content_type: :json do
include API::V3::Utilities::PathHelper
shared_let(:recipient) { FactoryBot.create :user }
shared_let(:other_recipient) { FactoryBot.create :user }
shared_let(:notification1) { FactoryBot.create :notification, recipient: recipient, read_email: true }
shared_let(:notification2) { FactoryBot.create :notification, recipient: recipient, read_email: true }
shared_let(:notification3) { FactoryBot.create :notification, recipient: recipient, read_email: true }
shared_let(:other_user_notification) { FactoryBot.create :notification, recipient: other_recipient, read_email: true }
let(:filters) { nil }
let(:read_path) do
api_v3_paths.path_for :notification_bulk_unread_email, filters: filters
end
let(:parsed_response) { JSON.parse(last_response.body) }
before do
login_as current_user
post read_path
end
describe 'POST /api/v3/notifications/unread_email' do
let(:current_user) { recipient }
it 'returns 204' do
expect(last_response.status)
.to eql(204)
end
it 'sets all the current users`s notifications to read' do
expect(::Notification.where(id: [notification1.id, notification2.id, notification3.id]).pluck(:read_email))
.to all(be_falsey)
expect(::Notification.where(id: [other_user_notification]).pluck(:read_email))
.to all(be_truthy)
end
context 'with a filter for id' do
let(:filters) do
[
{
'id' => {
'operator' => '=',
'values' => [notification1.id.to_s, notification2.id.to_s]
}
}
]
end
it 'sets the current users`s notifications matching the filter to read' do
expect(::Notification.where(id: [notification1.id, notification2.id]).pluck(:read_email))
.to all(be_falsey)
expect(::Notification.where(id: [other_user_notification, notification3.id]).pluck(:read_email))
.to all(be_truthy)
end
end
context 'with an invalid filter' do
let(:filters) do
[
{
'bogus' => {
'operator' => '=',
'values' => []
}
}
]
end
it 'returns 400' do
expect(last_response.status)
.to eql(400)
end
end
end
end

@ -1,82 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
require 'spec_helper'
describe ::API::V3::Notifications::NotificationsAPI,
'update email read status',
type: :request,
content_type: :json do
include API::V3::Utilities::PathHelper
shared_let(:recipient) { FactoryBot.create :user }
shared_let(:notification) { FactoryBot.create :notification, recipient: recipient }
let(:send_read) do
post api_v3_paths.notification_read_email(notification.id)
end
let(:send_unread) do
post api_v3_paths.notification_unread_email(notification.id)
end
let(:parsed_response) { JSON.parse(last_response.body) }
before do
login_as current_user
end
describe 'recipient user' do
let(:current_user) { recipient }
it 'can read and unread' do
send_read
expect(last_response.status).to eq(204)
expect(notification.reload.read_email).to be_truthy
send_unread
expect(last_response.status).to eq(204)
expect(notification.reload.read_email).to be_falsey
end
end
describe 'admin user' do
let(:current_user) { FactoryBot.build(:admin) }
it 'returns a 404 response' do
send_read
expect(last_response.status).to eq(404)
expect(notification.reload.read_email).to be_falsey
send_unread
expect(last_response.status).to eq(404)
expect(notification.reload.read_email).to be_falsey
end
end
end

@ -39,9 +39,6 @@ shared_examples 'represents the notification' do
expect(last_response.body)
.to(be_json_eql(notification.read_ian.to_json).at_path('readIAN'))
expect(last_response.body)
.to(be_json_eql(notification.read_email.to_json).at_path('readEmail'))
expect(last_response.body)
.to(be_json_eql(::API::V3::Utilities::DateTimeFormatter.format_datetime(notification.created_at).to_json).at_path('createdAt'))

@ -32,6 +32,29 @@ require 'spec_helper'
require 'services/base_services/behaves_like_create_service'
describe Notifications::CreateService, type: :model do
let(:mail_digest_before) { false }
before do
scope = double('scope')
allow(Notification)
.to receive(:mail_digest_before)
.with(recipient: model_instance.recipient, time: model_instance.created_at)
.and_return(scope)
allow(scope)
.to receive(:where)
.and_return(scope)
allow(scope)
.to receive(:not)
.and_return(scope)
allow(scope)
.to receive(:exists?)
.and_return(mail_digest_before)
end
it_behaves_like 'BaseServices create service' do
let(:call_attributes) do
{}
@ -50,11 +73,11 @@ describe Notifications::CreateService, type: :model do
context 'when mail ought to be send', { with_settings: { notification_email_delay_minutes: 30 } } do
let(:call_attributes) do
{
read_email: false
read_mail: false
}
end
it 'schedules a delayed event notification job' do
it 'schedules a delayed notification job' do
allow(Time)
.to receive(:now)
.and_return(Time.now)
@ -69,15 +92,66 @@ describe Notifications::CreateService, type: :model do
context 'when mail not ought to be send' do
let(:call_attributes) do
{
read_email: nil
read_mail: nil
}
end
it 'schedules no event notification job' do
it 'schedules no notification job' do
expect { subject }
.not_to have_enqueued_job(Mails::NotificationJob)
end
end
context 'when digests ought to be send' do
let(:call_attributes) do
{
read_mail_digest: false
}
end
before do
allow(model_instance.recipient)
.to receive(:time_zone)
.and_return(ActiveSupport::TimeZone['Tijuana'])
end
it 'schedules a digest mail job' do
expected_time = ActiveSupport::TimeZone['Tijuana'].parse(Setting.notification_email_digest_time) + 1.day
expect { subject }
.to have_enqueued_job(Mails::DigestJob)
.with({ "_aj_globalid" => "gid://open-project/User/#{model_instance.recipient.id}" })
.at(expected_time)
end
end
context 'when digests ought to be send and there is already a digest job scheduled' do
let(:mail_digest_before) { true }
let(:call_attributes) do
{
read_mail_digest: false
}
end
it 'schedules no digest mail job' do
expect { subject }
.not_to have_enqueued_job(Mails::DigestJob)
end
end
context 'when digests not ought to be send' do
let(:call_attributes) do
{
read_mail_digest: nil
}
end
it 'schedules no digest mail job' do
expect { subject }
.not_to have_enqueued_job(Mails::DigestJob)
end
end
end
context 'when unsuccessful' do

@ -49,13 +49,15 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:in_app_notification_setting, all: true)
FactoryBot.build(:in_app_notification_setting, all: true),
FactoryBot.build(:mail_digest_notification_setting, all: true)
]
end
let(:other_user_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: false, involved: false, watched: false, mentioned: false),
FactoryBot.build(:in_app_notification_setting, all: false, involved: false, watched: false, mentioned: false)
FactoryBot.build(:in_app_notification_setting, all: false, involved: false, watched: false, mentioned: false),
FactoryBot.build(:mail_digest_notification_setting, all: false, involved: false, watched: false, mentioned: false)
]
end
let(:user_property) { nil }
@ -118,34 +120,36 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
shared_examples_for 'creates notification' do
let(:sender) { author }
let(:event_reason) { :mentioned }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: false
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
it 'creates a notification' do
events_service = instance_double(Notifications::CreateService)
notifications_service = instance_double(Notifications::CreateService)
allow(Notifications::CreateService)
.to receive(:new)
.with(user: sender)
.and_return(events_service)
allow(events_service)
.and_return(notifications_service)
allow(notifications_service)
.to receive(:call)
call
expect(events_service)
expect(notifications_service)
.to have_received(:call)
.with({ recipient_id: recipient.id,
reason: event_reason,
project: journal.project,
actor: journal.user,
actor: sender,
journal: journal,
resource: journal.journable }.merge(event_channels))
resource: journal.journable }.merge(notification_channel_reasons))
end
end
@ -167,28 +171,42 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: true),
FactoryBot.build(:in_app_notification_setting, involved: true)
FactoryBot.build(:in_app_notification_setting, involved: true),
FactoryBot.build(:mail_digest_notification_setting, involved: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
context 'assignee has in app notifications disabled' do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:mail_notification_setting, involved: false, all: true),
FactoryBot.build(:mail_digest_notification_setting, involved: true),
FactoryBot.build(:in_app_notification_setting, involved: false)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: nil,
read_email: false
reason_ian: nil,
read_mail: false,
reason_mail: :subscribed,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
@ -198,16 +216,20 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: false),
FactoryBot.build(:mail_digest_notification_setting, involved: false),
FactoryBot.build(:in_app_notification_setting, involved: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: nil
reason_ian: :involved,
read_mail: nil,
reason_mail: nil,
read_mail_digest: nil,
reason_mail_digest: nil
}
end
end
@ -217,6 +239,7 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: false),
FactoryBot.build(:mail_digest_notification_setting, involved: false),
FactoryBot.build(:in_app_notification_setting, involved: false)
]
end
@ -228,16 +251,20 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: true),
FactoryBot.build(:mail_digest_notification_setting, involved: true),
FactoryBot.build(:in_app_notification_setting, involved: false, watched: false, mentioned: false, all: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :subscribed }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: false
reason_ian: :subscribed,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
@ -261,28 +288,42 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: true),
FactoryBot.build(:mail_digest_notification_setting, involved: true),
FactoryBot.build(:in_app_notification_setting, involved: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
context 'when responsible has in app notifications disabled' do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:mail_notification_setting, involved: false, all: true),
FactoryBot.build(:mail_digest_notification_setting, involved: false, all: true),
FactoryBot.build(:in_app_notification_setting, involved: false)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: nil,
read_email: false
reason_ian: nil,
read_mail: false,
reason_mail: :subscribed,
read_mail_digest: false,
reason_mail_digest: :subscribed
}
end
end
@ -292,16 +333,20 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: false),
FactoryBot.build(:mail_digest_notification_setting, involved: false),
FactoryBot.build(:in_app_notification_setting, involved: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: nil
reason_ian: :involved,
read_mail: nil,
reason_mail: nil,
read_mail_digest: nil,
reason_mail_digest: nil
}
end
end
@ -311,6 +356,7 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, involved: false),
FactoryBot.build(:mail_digest_notification_setting, involved: false),
FactoryBot.build(:in_app_notification_setting, involved: false)
]
end
@ -336,28 +382,42 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, watched: true),
FactoryBot.build(:mail_digest_notification_setting, watched: true),
FactoryBot.build(:in_app_notification_setting, watched: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :watched }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :watched,
read_mail: false,
reason_mail: :watched,
read_mail_digest: false,
reason_mail_digest: :watched
}
end
end
context 'when watcher has in app notifications disabled' do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, watched: true),
FactoryBot.build(:mail_digest_notification_setting, watched: true),
FactoryBot.build(:in_app_notification_setting, watched: false)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :watched }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: nil,
read_email: false
reason_ian: nil,
read_mail: false,
reason_mail: :watched,
read_mail_digest: false,
reason_mail_digest: :watched
}
end
end
@ -367,16 +427,20 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, watched: false),
FactoryBot.build(:mail_digest_notification_setting, watched: false),
FactoryBot.build(:in_app_notification_setting, watched: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :watched }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: nil
reason_ian: :watched,
read_mail: nil,
reason_mail: nil,
read_mail_digest: nil,
reason_mail_digest: nil
}
end
end
@ -386,6 +450,7 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, watched: false),
FactoryBot.build(:mail_digest_notification_setting, watched: false),
FactoryBot.build(:in_app_notification_setting, watched: false)
]
end
@ -406,28 +471,42 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:mail_digest_notification_setting, all: true),
FactoryBot.build(:in_app_notification_setting, all: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :subscribed }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :subscribed,
read_mail: false,
reason_mail: :subscribed,
read_mail_digest: false,
reason_mail_digest: :subscribed
}
end
end
context 'with in app notifications disabled' do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:mail_digest_notification_setting, all: true),
FactoryBot.build(:in_app_notification_setting, all: false)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :subscribed }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: nil,
read_email: false
reason_ian: nil,
read_mail: false,
reason_mail: :subscribed,
read_mail_digest: false,
reason_mail_digest: :subscribed
}
end
end
@ -437,16 +516,20 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: false),
FactoryBot.build(:mail_digest_notification_setting, all: false),
FactoryBot.build(:in_app_notification_setting, all: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :subscribed }
let(:event_channels) do
let(:notification_channel_reasons) do
{
read_ian: false,
read_email: nil
reason_ian: :subscribed,
read_mail: nil,
reason_mail: nil,
read_mail_digest: nil,
reason_mail_digest: nil
}
end
end
@ -456,6 +539,7 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: false),
FactoryBot.build(:mail_digest_notification_setting, all: false),
FactoryBot.build(:in_app_notification_setting, all: false)
]
end
@ -467,14 +551,25 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: false),
FactoryBot.build(:mail_digest_notification_setting, all: false),
FactoryBot.build(:in_app_notification_setting, all: false),
FactoryBot.build(:mail_notification_setting, project: project, all: true),
FactoryBot.build(:mail_digest_notification_setting, project: project, all: true),
FactoryBot.build(:in_app_notification_setting, project: project, all: true)
]
end
it_behaves_like 'creates notification' do
let(:event_reason) { :subscribed }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :subscribed,
read_mail: false,
reason_mail: :subscribed,
read_mail_digest: false,
reason_mail_digest: :subscribed
}
end
end
end
@ -482,8 +577,10 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, all: true),
FactoryBot.build(:mail_digest_notification_setting, all: true),
FactoryBot.build(:in_app_notification_setting, all: true),
FactoryBot.build(:mail_notification_setting, project: project, all: false),
FactoryBot.build(:mail_digest_notification_setting, project: project, all: false),
FactoryBot.build(:in_app_notification_setting, project: project, all: false)
]
end
@ -519,7 +616,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(work_package_updated) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
@ -527,7 +633,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(work_package_note_added) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
end
@ -546,7 +661,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(work_package_updated) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
@ -554,7 +678,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(status_updated) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
end
@ -573,7 +706,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(work_package_updated) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
@ -581,7 +723,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:notification_setting) { %w(work_package_priority_updated) }
it_behaves_like 'creates notification' do
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
end
@ -597,7 +748,16 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
it_behaves_like 'creates notification' do
let(:sender) { deleted_user }
let(:event_reason) { :involved }
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :involved,
read_mail: false,
reason_mail: :involved,
read_mail_digest: false,
reason_mail_digest: :involved
}
end
end
end
@ -605,6 +765,7 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, mentioned: true),
FactoryBot.build(:mail_digest_notification_setting, mentioned: true),
FactoryBot.build(:in_app_notification_setting, mentioned: true)
]
end
@ -612,13 +773,25 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
shared_examples_for 'group mention' do
context 'group member is allowed to view the work package' do
context 'user wants to receive notifications' do
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
context 'user disabled mention notifications' do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, mentioned: false),
FactoryBot.build(:mail_digest_notification_setting, mentioned: false),
FactoryBot.build(:in_app_notification_setting, mentioned: false)
]
end
@ -636,7 +809,18 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
context 'but group member is allowed individually' do
let(:role) { FactoryBot.create(:role, permissions: [:view_work_packages]) }
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
end
end
@ -648,20 +832,53 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
let(:note) { "Hello user:\"#{recipient_login}\"" }
context "that is pretty normal word" do
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
context "that is an email address" do
let(:recipient_login) { "foo@bar.com" }
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
end
context "The added text contains a user ID" do
let(:note) { "Hello user##{recipient.id}" }
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
context "The added text contains a user mention tag in one way" do
@ -671,7 +888,18 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
NOTE
end
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
context "The added text contains a user mention tag in the other way" do
@ -681,13 +909,25 @@ describe Notifications::JournalWpNotificationService, with_settings: { journal_a
NOTE
end
it_behaves_like 'creates notification'
it_behaves_like 'creates notification' do
let(:notification_channel_reasons) do
{
read_ian: false,
reason_ian: :mentioned,
read_mail: false,
reason_mail: :mentioned,
read_mail_digest: false,
reason_mail_digest: :mentioned
}
end
end
end
context "the recipient turned off mention notifications" do
let(:recipient_notification_settings) do
[
FactoryBot.build(:mail_notification_setting, mentioned: false),
FactoryBot.build(:mail_digest_notification_setting, mentioned: false),
FactoryBot.build(:in_app_notification_setting, mentioned: false)
]
end

@ -57,7 +57,9 @@ describe Notifications::SetAttributesService, type: :model do
end
let(:call_attributes) { {} }
let(:project) { FactoryBot.build_stubbed(:project) }
let(:reason) { :mentioned }
let(:reason_ian) { :mentioned }
let(:reason_mail) { :involved }
let(:reason_mail_digest) { :watched }
let(:journal) { FactoryBot.build_stubbed(:journal, journable: journable, data: journal_data) }
let(:journable) { nil }
let(:journal_data) { nil }
@ -68,7 +70,9 @@ describe Notifications::SetAttributesService, type: :model do
let(:call_attributes) do
{
recipient_id: recipient_id,
reason: reason,
reason_ian: reason_ian,
reason_mail: reason_mail,
reason_mail_digest: reason_mail_digest,
resource: journable,
journal: journal,
subject: event_subject,
@ -94,16 +98,19 @@ describe Notifications::SetAttributesService, type: :model do
expect(event.attributes.compact.symbolize_keys)
.to eql({
project_id: project.id,
reason: 'mentioned',
reason_ian: 'mentioned',
reason_mail: 'involved',
reason_mail_digest: 'watched',
journal_id: journal.id,
recipient_id: 1,
subject: event_subject,
read_ian: false,
read_email: false
read_mail: false,
read_mail_digest: false
})
end
context 'with only the minimal set of attributes for a work package journal' do
context 'with only the minimal set of attributes for a notification' do
let(:journable) do
FactoryBot.build_stubbed(:work_package, project: project).tap do |wp|
allow(wp)
@ -117,7 +124,9 @@ describe Notifications::SetAttributesService, type: :model do
let(:call_attributes) do
{
recipient_id: recipient_id,
reason: reason,
reason_ian: reason_ian,
reason_mail: reason_mail,
reason_mail_digest: reason_mail_digest,
journal: journal,
resource: journable,
}
@ -129,19 +138,21 @@ describe Notifications::SetAttributesService, type: :model do
expect(event.attributes.compact.symbolize_keys)
.to eql({
project_id: project.id,
reason: 'mentioned',
reason_ian: 'mentioned',
reason_mail: 'involved',
reason_mail_digest: 'watched',
resource_id: journable.id,
resource_type: 'WorkPackage',
journal_id: journal.id,
recipient_id: 1,
subject: I18n.t("notifications.work_packages.subject.#{reason}", work_package: journable.to_s),
read_ian: false,
read_email: false
read_mail: false,
read_mail_digest: false
})
end
end
it 'does not persist the member' do
it 'does not persist the notification' do
expect(event)
.not_to receive(:save)

@ -92,7 +92,7 @@ describe UserPreferences::UpdateService, 'integration', type: :model do
it 'inserts the setting, removing the old one' do
default = current_user.notification_settings.to_a
expect(default.count).to eq 2
expect(default.count).to eq 3
expect(subject.count).to eq 1
expect(subject.first.project_id).to eq project.id

@ -88,7 +88,7 @@ describe Users::SetAttributesService, type: :model do
it 'initalizes the notification settings' do
expect(call.result.notification_settings.length)
.to eql(2) # one for every channel
.to eql(3) # one for every channel
expect(call.result.notification_settings)
.to(all(be_a(NotificationSetting).and(be_new_record)))

@ -0,0 +1,39 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'support/pages/notifications/settings'
module Pages
module My
class Notifications < ::Pages::Notifications::Settings
def path
my_notifications_path
end
end
end
end

@ -46,21 +46,21 @@ module Pages
def expect_represented
user.notification_settings.each do |setting|
within_channel(setting.channel, project: setting.project&.name) do
expect_setting setting
expect_setting setting.attributes.symbolize_keys
end
end
end
def expect_setting(setting)
channel_name = I18n.t("js.notifications.channels.#{setting.channel}")
channel_name = I18n.t("js.notifications.channels.#{setting[:channel]}")
expect(page).to have_selector('td', text: channel_name)
%i[involved mentioned watched all].each do |type|
expect(page).to have_selector("input[type='checkbox'][data-qa-notification-type='#{type}']") do |checkbox|
if setting.all && type != :all
if setting[:all] && type != :all
checkbox.disabled?
else
checkbox.checked? == setting.send(type)
checkbox.checked? == setting[type]
end
end
end
@ -94,7 +94,7 @@ module Pages
end
def within_channel(channel, project: nil, &block)
raise(ArgumentError, "Invalid channel") unless %i[mail in_app].include?(channel.to_sym)
raise(ArgumentError, "Invalid channel") unless NotificationSetting.channels.include?(channel.to_sym)
project = 'global' if project.nil?
page.within(

@ -0,0 +1,152 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe Mails::DigestJob, type: :model do
subject(:job) { described_class.perform_now(recipient) }
let(:recipient) do
FactoryBot.build_stubbed(:user)
end
let(:notification_ids) { [1, 2, 3] }
let!(:notifications) do
instance_double(ActiveRecord::Relation).tap do |notifications|
allow(Time)
.to receive(:current)
.and_return(Time.current)
allow(Notification)
.to receive(:mail_digest_before)
.with(recipient: recipient, time: Time.current)
.and_return(notifications)
allow(notifications)
.to receive(:pluck)
.with(:id)
.and_return(notification_ids)
allow(Notification)
.to receive(:where)
.with(id: notification_ids)
.and_return(notifications)
allow(notifications)
.to receive(:update_all)
end
end
let(:mail) { instance_double(ActionMailer::MessageDelivery, deliver_now: nil) }
before do
# make sure no actual calls make it into the DigestMailer
allow(DigestMailer)
.to receive(:work_packages)
.with(recipient&.id, notification_ids)
.and_return(mail)
end
describe '#perform' do
context 'with successful mail sending' do
it 'sends a mail' do
job
expect(DigestMailer)
.to have_received(:work_packages)
.with(recipient.id, notification_ids)
end
it 'marks the notifications as read' do
job
expect(notifications)
.to have_received(:update_all)
.with(read_mail_digest: true, updated_at: Time.current)
end
it 'impersonates the recipient' do
allow(DigestMailer).to receive(:work_packages) do
expect(User.current)
.eql receiver
end
job
end
end
context 'without a recipient' do
let(:recipient) { nil }
it 'sends no mail' do
job
expect(DigestMailer)
.not_to have_received(:work_packages)
end
end
context 'with an error on mail rendering' do
before do
allow(DigestMailer)
.to receive(:work_packages)
.and_raise('error')
end
it 'swallows the error' do
expect { job }
.not_to raise_error
end
end
context 'with an error on mail sending' do
before do
allow(mail)
.to receive(:deliver_now)
.and_raise(SocketError)
end
it 'raises the error' do
expect { job }
.to raise_error(SocketError)
end
end
context 'with an empty list of notification ids' do
let(:notification_ids) { [] }
it 'sends no mail' do
job
expect(DigestMailer)
.not_to have_received(:work_packages)
end
end
end
end
Loading…
Cancel
Save