Compare commits

...

80 Commits

Author SHA1 Message Date
Oliver Günther afbfb25e70
Implement changed migration 4 years ago
Oliver Günther 426f3776a7
Disable ContentTag cop 4 years ago
Oliver Günther 65ef084c99
Add feature specs for notification settings 4 years ago
Oliver Günther 4e3d8734f2
Fix content_tag for bell icon 4 years ago
ulferts 1efa55be81
linting 4 years ago
ulferts 8add8da243
lint scss 4 years ago
ulferts 119cff3213
fix spec syntax 4 years ago
Oliver Günther d57674f492
Restore update spec 4 years ago
Oliver Günther 966b3cbe7f
Restore update method of notification settings 4 years ago
Oliver Günther dc91145047
Avoid has_many :notifcation_settings 4 years ago
ulferts 2e77553d4c
adapt specs 4 years ago
ulferts f9dec255b9
reenable no self notified for documents 4 years ago
ulferts cf54fddc28
adapt my_preferences references 4 years ago
ulferts 88fc2ab5c6
initialize notification settings for new user 4 years ago
ulferts 8e064e5e1f
replace remains of mail_notifications attribute 4 years ago
Oliver Günther effda48d01
Restyle table and toolbar 4 years ago
Oliver Günther 3d6cd9fbe6
Add aria-label to table checkboxes 4 years ago
Oliver Günther 5992ab0147
Add setting for self_notified and hook that up to the backend 4 years ago
Oliver Günther be7362fbd2
Rename notififcations store to user preferences store 4 years ago
Oliver Günther 4dcb508720
Remove notifications partial 4 years ago
Oliver Günther 494c829b24
Add notifications under admin/users 4 years ago
Oliver Günther de428a9d55
Add inline-create and table display grouped by project 4 years ago
ulferts a32302b60c
add project visible filter to project query 4 years ago
ulferts 51c87be322
migrate notification data 4 years ago
Oliver Günther 0d6c0b6bc7
Angular notifications frontend 4 years ago
ulferts 3cf09fd0d5
adapt specs 4 years ago
Oliver Günther 2cf0396bdb
Update service spec 4 years ago
Oliver Günther b9c713565a
Contract spec 4 years ago
Oliver Günther f8fe3014b7
Add specs for rendering and parsing notification settings 4 years ago
Oliver Günther fef1282de0
Remove unused patch endpoint 4 years ago
ulferts f92c8628fa
simplify methods 4 years ago
ulferts d6baa32cb0
concentrate wp notification in journal service 4 years ago
ulferts 28f0f5e9e9
consolidate wp#recipients 4 years ago
ulferts 964e06ff7b
extract notified_on_all 4 years ago
ulferts 1128e1a3eb
adapt work package notification creation to notification settings 4 years ago
Oliver Günther dd03b0c64a
Persist notifications 4 years ago
Oliver Günther cc9f0e62c9
API for notification settings 4 years ago
Oliver Günther 14fc45caa1
Add minimal feature spec for notification 4 years ago
Oliver Günther 83152ee177
Fix duplicate class in notification bell item 4 years ago
Oliver Günther c7e48ba4dd
Fix show resource spec 4 years ago
ulferts 838dee9a04
add notification_setting data layer 4 years ago
Oliver Günther 519cb4e380
Use timer for polling 4 years ago
Oliver Günther 4403e6d069
Fix frontend model for context 4 years ago
Oliver Günther eda0daada1
Fix link generation in polymorphic resources 4 years ago
Oliver Günther 49a7c6e14d
Take a stab at polymorphic representers 4 years ago
Oliver Günther 27e59cac1c
Merge unreadCount from store 4 years ago
ulferts 7b0a5300a7
create author watcher for existing work packages 4 years ago
ulferts edc613bf9b
renname parameter to reflect notification nature 4 years ago
ulferts b1ef3179b3
rename events to notifications 4 years ago
ulferts 6fe151969f
correct message call signature 4 years ago
ulferts 0df48b9880
author becomes watcher 4 years ago
ulferts 37c9eeb739
rename spec methods 4 years ago
ulferts 8b38494f50
route wp mail sending over events 4 years ago
ulferts 53e480075d
rename WPEventService 4 years ago
Oliver Günther b78531d839
Add event query 4 years ago
Oliver Günther 224727ce76
Merge remote-tracking branch 'origin/dev' into feature/26688/in-app-notifications 4 years ago
Oliver Günther 1e42f5757d
Switch to unread in notification 4 years ago
Oliver Günther 875fb79e84
Fix order on events 4 years ago
Oliver Günther 9260e3f53a
Wire up API to frontend 4 years ago
Oliver Günther d99bed43da
Add read/unread post actions to event API and add specs 4 years ago
Oliver Günther cfec3d64b6
Fix yml entry for mentioned 4 years ago
Oliver Günther 3961e21530
Fix typo in read_ian 4 years ago
Oliver Günther 10e89ff664
Fix polymorphic association raising exception for aggregated journals 4 years ago
Oliver Günther 5a5cc51455
Fix before hook in events API to after_validation 4 years ago
Oliver Günther a878206150
remove pry in event creation 4 years ago
Oliver Günther 135f499420
Fix setting yml key 4 years ago
Oliver Günther 93ad8216c8
Add specs for events API index/show 4 years ago
Oliver Günther 529dd95991
Hide bell notification when not logged 4 years ago
Oliver Günther e3c53c28ac
Add cleanup job for older events with a setting 4 years ago
Oliver Günther 671f4eb3f4
Send out events from WP notification mailer job 4 years ago
Oliver Günther 105d970425
Add events table, query and index 4 years ago
ulferts d7e7bd971f
wip specification for event api 4 years ago
Oliver Günther 87b77167c1
Add no results box 4 years ago
Oliver Günther eedf1fa1c5
Mark all read 4 years ago
Oliver Günther 898a502a8a
Toggle details of item 4 years ago
Oliver Günther 275d319f39
Style items 4 years ago
Oliver Günther 874b9ed5a1
Add notification modal and items 4 years ago
Oliver Günther b0d51d8375
Add fullscreen modal 4 years ago
Oliver Günther b508e974a1
Add in app notification in top menu 4 years ago
Oliver Günther f377213d26
Add bell icon to icon font 4 years ago
  1. 4
      .rubocop.yml
  2. 72
      app/contracts/notifications/create_contract.rb
  3. 46
      app/contracts/user_preferences/base_contract.rb
  4. 34
      app/contracts/user_preferences/update_contract.rb
  5. 20
      app/controllers/my_controller.rb
  6. 12
      app/controllers/users_controller.rb
  7. 4
      app/helpers/users_helper.rb
  8. 4
      app/models/journal/work_package_journal.rb
  9. 10
      app/models/mail_handler.rb
  10. 11
      app/models/notification.rb
  11. 11
      app/models/notification_setting.rb
  12. 57
      app/models/notification_settings/scopes/applicable.rb
  13. 14
      app/models/project.rb
  14. 43
      app/models/queries/notifications.rb
  15. 37
      app/models/queries/notifications/filters/notification_filter.rb
  16. 37
      app/models/queries/notifications/filters/read_ian_filter.rb
  17. 39
      app/models/queries/notifications/notification_query.rb
  18. 37
      app/models/queries/notifications/orders/default_order.rb
  19. 37
      app/models/queries/notifications/orders/read_ian_order.rb
  20. 37
      app/models/queries/notifications/orders/reason_order.rb
  21. 1
      app/models/queries/projects.rb
  22. 71
      app/models/queries/projects/filters/visible_filter.rb
  23. 70
      app/models/user.rb
  24. 2
      app/models/user_preference.rb
  25. 55
      app/models/users/scopes/notified_on_all.rb
  26. 8
      app/models/wiki_page.rb
  27. 39
      app/models/work_package.rb
  28. 3
      app/seeders/admin_user_seeder.rb
  29. 42
      app/services/notifications/create_service.rb
  30. 133
      app/services/notifications/journal_wp_notification_service.rb
  31. 57
      app/services/notifications/set_attributes_service.rb
  32. 39
      app/services/user_preferences/set_attributes_service.rb
  33. 92
      app/services/user_preferences/update_service.rb
  34. 7
      app/services/users/set_attributes_service.rb
  35. 4
      app/services/work_packages/copy_service.rb
  36. 22
      app/services/work_packages/create_service.rb
  37. 8
      app/views/admin/settings/general_settings/show.html.erb
  38. 1
      app/views/admin/settings/mail_notifications_settings/show.html.erb
  39. 1
      app/views/users/_form.html.erb
  40. 79
      app/views/users/_mail_notifications.html.erb
  41. 17
      app/views/users/_notifications.html.erb
  42. 4
      app/views/users/form/_mail_notifications.html.erb
  43. 38
      app/workers/mails/notification_job.rb
  44. 21
      app/workers/mails/watcher_job.rb
  45. 53
      app/workers/notifications/cleanup_job.rb
  46. 8
      config/initializers/menus.rb
  47. 7
      config/initializers/subscribe_listeners.rb
  48. 24
      config/locales/en.yml
  49. 15
      config/locales/js-en.yml
  50. 3
      config/routes.rb
  51. 5
      config/settings.yml
  52. 19
      db/migrate/20210616191052_create_notifications.rb
  53. 12
      db/migrate/20210618125430_authors_as_watchers.rb
  54. 123
      db/migrate/20210618132206_add_notification_settings.rb
  55. 78
      docs/api/apiv3/endpoints/notifications.apib
  56. 1
      docs/api/apiv3/endpoints/projects.apib
  57. 4
      frontend/src/app/app.module.ts
  58. 4
      frontend/src/app/core/apiv3/api-v3.service.ts
  59. 63
      frontend/src/app/core/apiv3/endpoints/notifications/apiv3-notification-paths.ts
  60. 69
      frontend/src/app/core/apiv3/endpoints/notifications/apiv3-notifications-paths.ts
  61. 4
      frontend/src/app/core/apiv3/endpoints/users/apiv3-user-paths.ts
  62. 62
      frontend/src/app/core/apiv3/endpoints/users/apiv3-user-preferences-paths.ts
  63. 2
      frontend/src/app/core/apiv3/endpoints/users/apiv3-users-paths.ts
  64. 2
      frontend/src/app/core/apiv3/paths/apiv3-list-resource.interface.ts
  65. 3
      frontend/src/app/core/apiv3/paths/apiv3-resource.ts
  66. 10
      frontend/src/app/core/apiv3/types/hal-collection.type.ts
  67. 2
      frontend/src/app/core/routing/openproject.routes.ts
  68. 19
      frontend/src/app/core/setup/global-dynamic-components.const.ts
  69. 2
      frontend/src/app/features/hal/resources/hal-resource.ts
  70. 16
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html
  71. 18
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.sass
  72. 41
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts
  73. 62
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html
  74. 13
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.sass
  75. 46
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts
  76. 48
      frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html
  77. 40
      frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.sass
  78. 21
      frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts
  79. 21
      frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts
  80. 21
      frontend/src/app/features/in-app-notifications/store/in-app-notification.model.ts
  81. 24
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts
  82. 91
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts
  83. 15
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts
  84. 2
      frontend/src/app/features/projects/components/new-project/new-project.component.ts
  85. 23
      frontend/src/app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component.html
  86. 69
      frontend/src/app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component.ts
  87. 34
      frontend/src/app/features/user-preferences/notifications-settings/page/notifications-settings-page.component.html
  88. 61
      frontend/src/app/features/user-preferences/notifications-settings/page/notifications-settings-page.component.ts
  89. 66
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.html
  90. 67
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.ts
  91. 76
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.html
  92. 6
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.sass
  93. 71
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.ts
  94. 22
      frontend/src/app/features/user-preferences/notifications-settings/toolbar/notifications-settings-toolbar.component.html
  95. 33
      frontend/src/app/features/user-preferences/notifications-settings/toolbar/notifications-settings-toolbar.component.ts
  96. 29
      frontend/src/app/features/user-preferences/state/notification-setting.model.ts
  97. 11
      frontend/src/app/features/user-preferences/state/user-preferences.model.ts
  98. 33
      frontend/src/app/features/user-preferences/state/user-preferences.query.ts
  99. 57
      frontend/src/app/features/user-preferences/state/user-preferences.service.ts
  100. 51
      frontend/src/app/features/user-preferences/state/user-preferences.store.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -108,6 +108,10 @@ Naming/PredicateName:
Rails/SkipsModelValidations:
Enabled: false
# Don't force us to use tag instead of content_tag
# as this breaks angular elements
Rubocop/Rails/ContentTag:
enabled: false
# For feature specs, we tend to have longer specs that cover a larger part of the functionality.
# This is done for multiple reasons:

@ -0,0 +1,72 @@
#-- 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
class CreateContract < ::ModelContract
attribute :recipient
attribute :subject
attribute :reason
attribute :context
attribute :context_type
attribute :resource
attribute :resource_type
attribute :read_ian
attribute :read_email
validate :validate_recipient_present
validate :validate_subject_present
validate :validate_reason_present
validate :validate_channels
def validate_recipient_present
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?
end
def validate_channels
if model.read_ian == nil && model.read_email == nil
errors.add(:base, :at_least_one_channel)
end
if model.read_ian
errors.add(:read_ian, :read_on_creation)
end
if model.read_email
errors.add(:read_email, :read_on_creation)
end
end
end
end

@ -0,0 +1,46 @@
#-- 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 UserPreferences
class BaseContract < ::BaseContract
validate :user_allowed_to_access
protected
##
# User preferences can only be accessed with the manage_user permission
# or if an active, logged user is editing their own prefs
def user_allowed_to_access
unless user.allowed_to_globally?(:manage_user) || (user.logged? && user.active? && user.id == model.user_id)
errors.add :base, :error_unauthorized
end
end
end
end

@ -0,0 +1,34 @@
#-- 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 UserPreferences
class UpdateContract < BaseContract
end
end

@ -45,7 +45,7 @@ class MyController < ApplicationController
menu_item :settings, only: [:settings]
menu_item :password, only: [:password]
menu_item :access_token, only: [:access_token]
menu_item :mail_notifications, only: [:mail_notifications]
menu_item :notifications, only: [:notifications]
def account; end
@ -82,10 +82,10 @@ class MyController < ApplicationController
def access_token; end
# Configure user's mail notifications
def mail_notifications; end
def update_mail_notifications
write_email_settings(redirect_to: :mail_notifications)
def notifications
render html: '',
layout: 'angular',
locals: { menu_name: :my_menu }
end
# Create a new feeds key
@ -141,16 +141,6 @@ class MyController < ApplicationController
false
end
def write_email_settings(redirect_to:)
update_service = UpdateUserEmailSettingsService.new(@user)
if update_service.call(mail_notification: permitted_params.user[:mail_notification],
self_notified: params[:self_notified] == '1',
notified_project_ids: params[:notified_project_ids])
flash[:notice] = I18n.t(:notice_account_updated)
redirect_to(action: redirect_to)
end
end
def write_settings
user_params = permitted_params.my_account_settings

@ -95,13 +95,11 @@ class UsersController < ApplicationController
end
def new
@user = User.new(language: Setting.default_language,
mail_notification: Setting.default_notification_option)
@user = User.new(language: Setting.default_language)
end
def create
@user = User.new(language: Setting.default_language,
mail_notification: Setting.default_notification_option)
@user = User.new(language: Setting.default_language)
@user.attributes = permitted_params.user_create_as_admin(false, @user.change_password_allowed?)
@user.admin = params[:user][:admin] || false
@user.login = params[:user][:login] || @user.mail
@ -127,15 +125,9 @@ class UsersController < ApplicationController
def update
update_params = build_user_update_params
mail_notification = update_params.delete(:mail_notification)
call = ::Users::UpdateService.new(model: @user, user: current_user).call(update_params)
if call.success?
update_email_service = UpdateUserEmailSettingsService.new(@user)
update_email_service.call(mail_notification: mail_notification,
self_notified: params[:self_notified] == '1',
notified_project_ids: params[:notified_project_ids])
if update_params[:password].present? && @user.change_password_allowed?
send_information = params[:send_information]

@ -129,10 +129,6 @@ module UsersHelper
content_tag('option', "--- #{I18n.t(:actionview_instancetag_blank_option)} ---") + options
end
def user_mail_notification_options(user)
user.valid_notification_options.map { |o| [I18n.t(o.last), o.first] }
end
def user_name(user)
user ? user.name : I18n.t('user.deleted')
end

@ -30,4 +30,8 @@
class Journal::WorkPackageJournal < Journal::BaseJournal
self.table_name = 'work_package_journals'
belongs_to :project
belongs_to :assigned_to, class_name: 'Principal'
belongs_to :responsible, class_name: 'Principal'
end

@ -291,9 +291,13 @@ class MailHandler < ActionMailer::Base
user.allowed_to?("add_#{obj.class.lookup_ancestors.last.name.underscore}_watchers".to_sym, obj.project)
addresses = [email.to, email.cc].flatten.compact.uniq.map { |a| a.strip.downcase }
unless addresses.empty?
watchers = User.active.where(['LOWER(mail) IN (?)', addresses])
watchers.each do |w|
obj.add_watcher(w)
User
.active
.where(['LOWER(mail) IN (?)', addresses])
.each do |user|
Services::CreateWatcher
.new(obj, user)
.run
end
# FIXME: somehow the watchable attribute of the new watcher is not set, when the issue is not safed.
# So we fix that here manually

@ -0,0 +1,11 @@
class Notification < ApplicationRecord
enum reason: { mentioned: 0, involved: 1, watched: 2, subscribed: 3 }
belongs_to :recipient, class_name: 'User'
belongs_to :actor, class_name: 'User'
belongs_to :project
belongs_to :journal
belongs_to :resource, polymorphic: true
scope :recipient, ->(user) { where(recipient_id: user.is_a?(User) ? user.id : user) }
end

@ -0,0 +1,11 @@
class NotificationSetting < ApplicationRecord
enum channel: { in_app: 0, mail: 1 }
belongs_to :project
belongs_to :user
include Scopes::Scoped
scopes :applicable
validates :channel, uniqueness: { scope: %i[project user] }
end

@ -0,0 +1,57 @@
#-- 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 NotificationSettings::Scopes
module Applicable
extend ActiveSupport::Concern
class_methods do
# Return notifications settings that prevail in the selected context (project)
# If there is only the global notification setting in place, those are authoritative.
# If there is a project specific setting in place, it is the project specific setting instead.
# rubocop:disable Metrics/AbcSize
def applicable(project)
global_notifications = NotificationSetting.arel_table
project_notifications = NotificationSetting.arel_table.alias('project_settings')
subselect = global_notifications
.where(global_notifications[:project_id].eq(nil))
.join(project_notifications, Arel::Nodes::OuterJoin)
.on(project_notifications[:project_id].eq(project.id),
global_notifications[:user_id].eq(project_notifications[:user_id]),
global_notifications[:channel].eq(project_notifications[:channel]))
.project(global_notifications.coalesce(project_notifications[:id], global_notifications[:id]))
where(global_notifications[:id].in(subselect))
end
# rubocop:enable Metrics/AbcSize
end
end
end

@ -86,6 +86,7 @@ class Project < ApplicationRecord
association_foreign_key: 'custom_field_id'
has_one :status, class_name: 'Projects::Status', dependent: :destroy
has_many :budgets, dependent: :destroy
has_many :notification_settings, dependent: :destroy
acts_as_nested_set order_column: :name, dependent: :destroy
@ -275,16 +276,11 @@ class Project < ApplicationRecord
notified_users
end
# Returns the users that should be notified on project events
# Return all users who want to be notified on every event within a project.
# If there is only the global notification setting in place, that one is authoritative.
# If there is a project specific setting in place, it is the project specific setting instead.
def notified_users
# TODO: User part should be extracted to User#notify_about?
notified_members = members.select do |member|
setting = member.principal.mail_notification
(setting == 'selected' && member.mail_notification?) || setting == 'all'
end
notified_members.map(&:principal)
User.notified_on_all(self)
end
# Returns an array of all custom fields enabled for project issues

@ -0,0 +1,43 @@
#-- 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-2017 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 Queries::Notifications
Queries::Register.filter Queries::Notifications::NotificationQuery,
Queries::Notifications::Filters::ReadIanFilter
Queries::Register.order Queries::Notifications::NotificationQuery,
Queries::Notifications::Orders::DefaultOrder
Queries::Register.order Queries::Notifications::NotificationQuery,
Queries::Notifications::Orders::ReasonOrder
Queries::Register.order Queries::Notifications::NotificationQuery,
Queries::Notifications::Orders::ReadIanOrder
end

@ -0,0 +1,37 @@
#-- 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 Queries::Notifications::Filters::NotificationFilter < Queries::Filters::Base
self.model = Notification
def human_name
Notification.human_attribute_name(name)
end
end

@ -0,0 +1,37 @@
#-- 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 Queries::Notifications::Filters::ReadIanFilter < Queries::Notifications::Filters::NotificationFilter
include Queries::Filters::Shared::BooleanFilter
def self.key
:read_ian
end
end

@ -0,0 +1,39 @@
#-- 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-2017 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 Queries::Notifications::NotificationQuery < Queries::BaseQuery
def self.model
Notification
end
def default_scope
Notification.recipient(user)
end
end

@ -0,0 +1,37 @@
#-- 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 Queries::Notifications::Orders::DefaultOrder < Queries::BaseOrder
self.model = Notification
def self.key
/\A(id|created_at|updated_at)\z/
end
end

@ -0,0 +1,37 @@
#-- 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 Queries::Notifications::Orders::ReadIanOrder < Queries::BaseOrder
self.model = Notification
def self.key
:read_ian
end
end

@ -0,0 +1,37 @@
#-- 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 Queries::Notifications::Orders::ReasonOrder < Queries::BaseOrder
self.model = Notification
def self.key
:reason
end
end

@ -48,6 +48,7 @@ module Queries::Projects
filter query, filters::IdFilter
filter query, filters::ProjectStatusFilter
filter query, filters::UserActionFilter
filter query, filters::VisibleFilter
order query, orders::DefaultOrder
order query, orders::LatestActivityAtOrder

@ -0,0 +1,71 @@
#-- 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.
#++
# Returns projects visible for a user.
# This filter is only useful for admins which want to scope down the list of all the projects to those
# visible by a user. For a non admin user, the vanilla project query is already limited to the visible projects.
module Queries
module Projects
module Filters
class VisibleFilter < ::Queries::Projects::Filters::ProjectFilter
validate :validate_only_single_value
def allowed_values
# Disregard the need for a proper name (as it is no longer actually displayed)
# in favor of speed.
@allowed_values ||= User.pluck(:id, :id)
end
def scope
super.where(id: Project.visible(User.find(values.first)))
end
def where
# Handled by scope
'1 = 1'
end
def type
:list
end
def available_operators
[::Queries::Operators::Equals]
end
private
def validate_only_single_value
errors.add(:values, :invalid) if values.length != 1
end
end
end
end
end

@ -39,22 +39,6 @@ class User < Principal
username: [:login]
}.freeze
USER_MAIL_OPTION_ALL = ['all', :label_user_mail_option_all].freeze
USER_MAIL_OPTION_SELECTED = ['selected', :label_user_mail_option_selected].freeze
USER_MAIL_OPTION_ONLY_MY_EVENTS = ['only_my_events', :label_user_mail_option_only_my_events].freeze
USER_MAIL_OPTION_ONLY_ASSIGNED = ['only_assigned', :label_user_mail_option_only_assigned].freeze
USER_MAIL_OPTION_ONLY_OWNER = ['only_owner', :label_user_mail_option_only_owner].freeze
USER_MAIL_OPTION_NON = ['none', :label_user_mail_option_none].freeze
MAIL_NOTIFICATION_OPTIONS = [
USER_MAIL_OPTION_ALL,
USER_MAIL_OPTION_SELECTED,
USER_MAIL_OPTION_ONLY_MY_EVENTS,
USER_MAIL_OPTION_ONLY_ASSIGNED,
USER_MAIL_OPTION_ONLY_OWNER,
USER_MAIL_OPTION_NON
].freeze
include ::Associations::Groupable
extend DeprecatedAlias
@ -82,13 +66,16 @@ class User < Principal
class_name: 'Doorkeeper::Application',
as: :owner
has_many :notification_settings, dependent: :destroy
# Users blocked via brute force prevention
# use lambda here, so time is evaluated on each query
scope :blocked, -> { create_blocked_scope(self, true) }
scope :not_blocked, -> { create_blocked_scope(self, false) }
scopes :find_by_login,
:newest
:newest,
:notified_on_all
def self.create_blocked_scope(scope, blocked)
scope.where(blocked_condition(blocked))
@ -123,7 +110,6 @@ class User < Principal
validates_format_of :mail, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, allow_blank: true
validates_length_of :mail, maximum: 256, allow_nil: true
validates_confirmation_of :password, allow_nil: true
validates_inclusion_of :mail_notification, in: MAIL_NOTIFICATION_OPTIONS.map(&:first), allow_blank: true
auto_strip_attributes :login, nullify: false
auto_strip_attributes :mail, nullify: false
@ -133,8 +119,6 @@ class User < Principal
after_save :update_password
before_create :sanitize_mail_notification_setting
scope :admin, -> { where(admin: true) }
def self.unique_attribute
@ -142,11 +126,6 @@ class User < Principal
end
prepend ::Mixins::UniqueFinder
def sanitize_mail_notification_setting
self.mail_notification = Setting.default_notification_option if mail_notification.blank?
true
end
def current_password
passwords.first
end
@ -400,41 +379,6 @@ class User < Principal
pref.comments_in_reverse_order?
end
# Return an array of project ids for which the user has explicitly turned mail notifications on
def notified_projects_ids
@notified_projects_ids ||= memberships.reload.select(&:mail_notification?).map(&:project_id)
end
def notified_project_ids=(ids)
Member
.where(user_id: id)
.update_all(mail_notification: false)
if ids && !ids.empty?
Member
.where(user_id: id, project_id: ids)
.update_all(mail_notification: true)
end
@notified_projects_ids = nil
notified_projects_ids
end
def valid_notification_options
self.class.valid_notification_options(self)
end
# Only users that belong to more than 1 project can select projects for which they are notified
def self.valid_notification_options(user = nil)
# Note that @user.membership.size would fail since AR ignores
# :include association option when doing a count
if user.nil? || user.memberships.length < 1
MAIL_NOTIFICATION_OPTIONS.reject { |option| option.first == 'selected' }
else
MAIL_NOTIFICATION_OPTIONS
end
end
# Find a user account by matching the exact login and then a case-insensitive
# version. Exact matches will be given priority.
def self.find_by_login(login)
@ -587,12 +531,6 @@ class User < Principal
authorization_service.preload_projects_allowed_to(action)
end
# Utility method to help check if a user should be notified about an
# event.
def notify_about?(object)
active? && (mail_notification == 'all' || (object.is_a?(WorkPackage) && object.notify?(self)))
end
def reported_work_package_count
WorkPackage.on_active_project.with_author(self).visible.count
end

@ -32,6 +32,8 @@ class UserPreference < ApplicationRecord
belongs_to :user
serialize :others
delegate :notification_settings, to: :user
validates_presence_of :user
validate :time_zone_correctness, if: -> { time_zone.present? }

@ -0,0 +1,55 @@
#-- 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.
#++
# Return all users who want to be notified on every activity within a project.
# If there is only the global notification setting in place, that one is authoritative.
# If there is a project specific setting in place, it is the project specific setting instead.
module Users::Scopes
module NotifiedOnAll
extend ActiveSupport::Concern
class_methods do
def notified_on_all(project)
global_settings = NotificationSetting
.where(all: true, project: nil)
project_settings_not_all = NotificationSetting
.where(project: project)
.group(:user_id)
.having('NOT bool_or("all")')
project_settings = NotificationSetting
.where(all: true, project: project)
where(id: global_settings.select(:user_id))
.where.not(id: project_settings_not_all.select(:user_id))
.or(User.where(id: project_settings.select(:user_id)))
end
end
end
end

@ -143,7 +143,7 @@ class WikiPage < ApplicationRecord
end
def content_for_version(version = nil)
journal = content.versions.find_by(version: version.to_i) if version
journal = content.journals.find_by(version: version.to_i) if version
if journal.nil? || content.version == journal.version
content
@ -161,15 +161,15 @@ class WikiPage < ApplicationRecord
version_from = version_from ? version_from.to_i : version_to - 1
version_to, version_from = version_from, version_to unless version_from < version_to
content_to = content.versions.find_by(version: version_to)
content_from = content.versions.find_by(version: version_from)
content_to = content.journals.find_by(version: version_to)
content_from = content.journals.find_by(version: version_from)
content_to && content_from ? Wikis::Diff.new(content_to, content_from) : nil
end
def annotate(version = nil)
version = version ? version.to_i : content.version
c = content.versions.find_by(version: version)
c = content.journals.find_by(version: version)
c ? Wikis::Annotate.new(c) : nil
end

@ -293,30 +293,6 @@ class WorkPackage < ApplicationRecord
end
alias_method :is_milestone?, :milestone?
# Returns users that should be notified
def recipients
notified = project.notified_users + attribute_users.select { |u| u.notify_about?(self) }
notified.uniq!
# Remove users that can not view the work package
notified & User.allowed(:view_work_packages, project)
end
def notify?(user)
case user.mail_notification
when 'selected', 'only_my_events'
author == user || user.is_or_belongs_to?(assigned_to) || user.is_or_belongs_to?(responsible)
when 'none'
false
when 'only_assigned'
user.is_or_belongs_to?(assigned_to) || user.is_or_belongs_to?(responsible)
when 'only_owner'
author == user
else
false
end
end
def done_ratio
if WorkPackage.use_status_for_done_ratio? && status && status.default_done_ratio
status.default_done_ratio
@ -682,19 +658,4 @@ class WorkPackage < ApplicationRecord
errors.messages[:attachments].first << " - #{invalid_attachment.errors.full_messages.first}"
end
end
def attribute_users
related = [author]
[responsible, assigned_to].each do |principal|
case principal
when Group
related += principal.users.active
when User
related << principal
end
end
related.select(&:present?)
end
end

@ -54,10 +54,11 @@ class AdminUserSeeder < Seeder
user.firstname = 'OpenProject'
user.lastname = 'Admin'
user.mail = ENV['ADMIN_EMAIL'].presence || 'admin@example.net'
user.mail_notification = User::USER_MAIL_OPTION_ONLY_MY_EVENTS.first
user.language = I18n.locale.to_s
user.status = User.statuses[:active]
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)
end
end

@ -0,0 +1,42 @@
#-- 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 Notifications::CreateService < ::BaseServices::Create
protected
def after_perform(call)
super.tap do |result|
if result.success?
Mails::NotificationJob
.perform_later(result.result, user)
end
end
end
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Notifications::JournalWpMailService
class Notifications::JournalWpNotificationService
MENTION_USER_ID_PATTERN =
'<mention[^>]*(?:data-type="user"[^>]*data-id="(\d+)")|(?:data-id="(\d+)"[^>]*data-type="user")[^>]*>)|(?:\buser#(\d+)\b'
.freeze
@ -40,30 +40,109 @@ class Notifications::JournalWpMailService
MENTION_PATTERN = Regexp.new("(?:#{MENTION_USER_ID_PATTERN})|(?:#{MENTION_USER_LOGIN_PATTERN})|(?:#{MENTION_GROUP_ID_PATTERN})")
class << self
def call(journal, send_mails)
journal_complete_mail(journal, send_mails)
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) do |recipient, reason|
create_event(journal, recipient, reason, author)
end
end
private
def journal_complete_mail(journal, send_mails)
return nil if abort_sending?(journal, send_mails)
def create_event(journal, recipient, reason, user)
notification_attributes = {
recipient: recipient,
reason: reason,
project: journal.project,
resource: journal.journable,
journal: journal,
actor: journal.user
}.merge(channel_attributes(recipient))
Notifications::CreateService
.new(user: user)
.call(notification_attributes)
end
def channel_attributes(recipient)
key =
case reason
when :subscribed
:all
else
reason
end
author = User.find_by(id: journal.user_id) || DeletedUser.first
recipient.notification_settings.map do |setting|
channel = case setting.channel
when 'mail'
:read_email
when 'in_app'
:read_ian
else
raise "Unknown notification channel"
end
notification_receivers(journal.journable, journal).each do |recipient|
Mails::WorkPackageJob.perform_later(journal.id, recipient.id, author.id)
end
[channel, setting[key] ? false : nil]
end.to_h
end
def notification_receivers(work_package, journal)
(work_package.recipients + work_package.watcher_recipients + mentioned(work_package, journal)).uniq
seen = mentioned(journal).each do |user|
yield(user, :mentioned)
end
seen += involved(journal, seen).each do |user|
yield(user, :involved)
end
seen += subscribed(journal, seen).each do |user|
yield(user, :subscribed)
end
watched(work_package, seen).each do |user|
yield(user, :watched)
end
end
def mentioned(journal)
allowed_and_unique(mentioned_ids(journal),
journal.data.project,
[])
end
def mentioned(work_package, journal)
mentioned_ids(journal)
.where(id: User.allowed(:view_work_packages, work_package.project))
.where.not(mail_notification: User::USER_MAIL_OPTION_NON.first)
def involved(journal, seen)
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,
seen)
end
def subscribed(journal, seen)
project = journal.data.project
allowed_and_unique(User.notified_on_all(project),
project,
seen)
end
def watched(work_package, seen)
allowed_and_unique(work_package.watcher_users,
work_package.project,
seen)
end
def allowed_and_unique(scope, project, seen = [])
scope
.where(id: User.allowed(:view_work_packages, project))
.where.not(id: seen.map(&:id))
.not_builtin
end
def text_for_mentions(journal)
@ -74,7 +153,7 @@ class Notifications::JournalWpMailService
details = journal.details[field]
if details.present?
potential_text << "\n" + Redmine::Helpers::Diff.new(*details.reverse).additions.join(' ')
potential_text << "\n#{Redmine::Helpers::Diff.new(*details.reverse).additions.join(' ')}"
end
end
potential_text
@ -84,8 +163,8 @@ class Notifications::JournalWpMailService
matches = mention_matches(journal)
base_scope = User
.includes(:groups)
.references(:groups_users)
.includes(:groups)
.references(:groups_users)
by_id = base_scope.where(id: matches[:user_ids])
by_login = base_scope.where(login: matches[:user_login_names])
@ -96,11 +175,11 @@ class Notifications::JournalWpMailService
.or(by_group)
end
def send_mail?(journal, send_mails)
send_mails && ::UserMailer.perform_deliveries && send_mail_setting?(journal)
def send_notification?(journal, send_notifications)
send_notifications && ::UserMailer.perform_deliveries && send_notification_setting?(journal)
end
def send_mail_setting?(journal)
def send_notification_setting?(journal)
notify_for_wp_added?(journal) ||
notify_for_wp_updated?(journal) ||
notify_for_notes?(journal) ||
@ -118,9 +197,9 @@ class Notifications::JournalWpMailService
group_ids_tag_after,
group_ids_tag_before,
group_ids_hash = text
.scan(MENTION_PATTERN)
.transpose
.each(&:compact!)
.scan(MENTION_PATTERN)
.transpose
.each(&:compact!)
{
user_ids: [user_ids_tag_after, user_ids_tag_before, user_ids_hash].flatten.compact,
@ -155,8 +234,12 @@ class Notifications::JournalWpMailService
Setting.notified_events.include?(name)
end
def abort_sending?(journal, send_mails)
!send_mail?(journal, send_mails) || journal.noop?
def abort_sending?(journal, send_notifications)
!send_notification?(journal, send_notifications) || journal.noop?
end
def group_or_user_ids(association)
association.is_a?(Group) ? association.user_ids : association&.id
end
end
end

@ -0,0 +1,57 @@
#-- 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
class SetAttributesService < ::BaseServices::SetAttributes
private
def set_default_attributes(params)
super
set_default_subject unless model.subject
set_default_context unless model.context
end
def set_default_subject
# TODO: Work package journal specific.
# Extract into strategy per event resource
journable = model.resource.journable
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
def set_default_context
# TODO: Work package journal specific.
# Extract into strategy per event resource
model.context = model.resource.data.project
end
end
end

@ -0,0 +1,39 @@
#-- 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 UserPreferences
class SetAttributesService < ::BaseServices::SetAttributes
def set_attributes(params)
params.each do |k, v|
model[k] = v
end
end
end
end

@ -0,0 +1,92 @@
#-- 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 UserPreferences
class UpdateService < ::BaseServices::Update
protected
attr_accessor :notifications
def before_perform(params)
self.notifications = params&.delete(:notification_settings)
super
end
def after_perform(service_call)
return service_call if notifications.nil?
inserted = persist_notifications
remove_other_notifications(inserted)
service_call
end
def persist_notifications
global, project = notifications
.map { |item| item.merge(user_id: model.user_id) }
.partition { |setting| setting[:project_id].nil? }
global_ids = upsert_notifications(global, %i[user_id channel], 'project_id IS NULL')
project_ids = upsert_notifications(project, %i[user_id channel project_id], 'project_id IS NOT NULL')
global_ids + project_ids
end
def remove_other_notifications(ids)
NotificationSetting
.where(user_id: model.user_id)
.where.not(id: ids)
.delete_all
end
##
# Upsert notification while respecting the partial index on notification_settings
# depending on the project presence
#
# @param notifications The array of notification hashes to upsert
# @param conflict_target The uniqueness constraint to upsert within
# @param index_predicate The partial index condition on the project
def upsert_notifications(notifications, conflict_target, index_predicate)
return [] if notifications.empty?
NotificationSetting
.import(
notifications,
on_duplicate_key_update: {
conflict_target: conflict_target,
index_predicate: index_predicate,
columns: %i[watched involved mentioned all]
},
validate: false
).ids
end
end
end

@ -45,10 +45,17 @@ module Users
if model.invited? && model.mail.present?
::UserInvitation.assign_user_attributes model
end
initialize_notification_settings unless model.notification_settings.any?
end
def set_preferences(user_preferences)
model.pref.attributes = user_preferences if user_preferences
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)
end
end
end

@ -85,8 +85,8 @@ class WorkPackages::CopyService
end
def copy_watchers(copied)
work_package.watchers.each do |watcher|
copied.add_watcher(watcher.user) if watcher.user.active?
work_package.watcher_users.each do |user|
copied.add_watcher(user) if user.active?
end
end
end

@ -41,8 +41,8 @@ class WorkPackages::CreateService < ::BaseServices::BaseCallable
end
def perform(work_package: WorkPackage.new,
send_notifications: true,
**attributes)
send_notifications: true,
**attributes)
in_user_context(send_notifications) do
create(attributes, work_package)
end
@ -54,8 +54,7 @@ class WorkPackages::CreateService < ::BaseServices::BaseCallable
result = set_attributes(attributes, work_package)
result.success = if result.success
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
work_package.save
replace_attachments(work_package)
else
false
end
@ -66,6 +65,8 @@ class WorkPackages::CreateService < ::BaseServices::BaseCallable
update_ancestors_all_attributes(result.all_results).each do |ancestor_result|
result.merge!(ancestor_result)
end
set_user_as_watcher(work_package)
else
result.success = false
end
@ -81,6 +82,11 @@ class WorkPackages::CreateService < ::BaseServices::BaseCallable
.call(attributes)
end
def replace_attachments(work_package)
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
work_package.save
end
def reschedule_related(work_package)
result = WorkPackages::SetScheduleService
.new(user: user,
@ -97,6 +103,14 @@ class WorkPackages::CreateService < ::BaseServices::BaseCallable
result
end
def set_user_as_watcher(work_package)
# We don't care if it fails here. If it does
# the user simply does not become watcher
Services::CreateWatcher
.new(work_package, user)
.run(send_notifications: false)
end
def attributes_service_class
::WorkPackages::SetAttributesService
end

@ -42,7 +42,13 @@ See docs/COPYRIGHT.rdoc for more details.
</span>
</div>
<div class="form--field">
<%= setting_text_field :activity_days_default, size: 6, unit: t(:label_day_plural), container_class: '-xslim' %>
<%= setting_number_field :activity_days_default, size: 6, unit: t(:label_day_plural), container_class: '-xslim' %>
</div>
<div class="form--field">
<%= setting_number_field :notification_retention_period_days, size: 6, min: 1, unit: t(:label_day_plural), container_class: '-xslim' %>
<span class="form--field-instructions">
<%= t(:'settings.notifications.retention_text') %>
</span>
</div>
<div class="form--field">
<%= setting_text_field :host_name, size: 60, container_class: '-middle' %>

@ -37,7 +37,6 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field"><%= setting_text_field :mail_from, size: 60, container_class: '-middle' %></div>
<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_select(:default_notification_option, User.valid_notification_options.collect {|o| [t(o.last), o.first.to_s]}, container_class: '-middle') %></div>
</section>
<fieldset id="notified_events" class="form--fieldset">

@ -51,6 +51,5 @@ See docs/COPYRIGHT.rdoc for more details.
<%= render partial: 'users/form/authentication/form', locals: { f: f } %>
<% if current_user.admin? %>
<%= render partial: 'users/form/mail_notifications', locals: { f: f } %>
<%= render partial: 'users/form/preferences', locals: { f: f } %>
<% end %>

@ -1,79 +0,0 @@
<%#-- 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.
++#%>
<% active_sections = if @user.mail_notification == 'selected'
[{key: 'notified_projects'}]
else
[]
end %>
<%= initialize_hide_sections_with [{key: 'notified_projects'}], active_sections %>
<div class="form--field">
<%= styled_label_tag "user_mail_notification", t(:'user.settings.mail_notifications') %>
<div class="form--field-container">
<div class="form--select-container">
<%= styled_select_tag 'user[mail_notification]',
options_for_select(user_mail_notification_options(@user), @user.mail_notification),
container_class: '-wide' %>
<show-section-dropdown data-opt-value="selected"
data-hide-sec-with-name="notified_projects">
</show-section-dropdown>
</div>
</div>
</div>
<section class="hide-section" data-section-name="notified_projects" <%= @user.mail_notification == 'selected' ? '' : 'hidden' %>>
<div id="notified-projects" class="form--field -no-label">
<div class="form--field-container -vertical">
<% @user.projects.distinct.each do |project| %>
<label class="form--label-with-check-box">
<%= styled_check_box_tag 'notified_project_ids[]',
project.id,
@user.notified_projects_ids.include?(project.id),
id: "notified_project_ids_#{project.id}" %>
<%= project.name %>
</label>
<% end %>
</div>
<div class="form--field-instructions">
<%= t(:'user.settings.mail_project_explanaition') %>
</div>
</div>
</section>
<div class="form--field">
<%= styled_label_tag 'self_notified', t(:'user.settings.mail_self_notified') %>
<div class="form--field-container">
<div class="form--check-box-container">
<%= styled_check_box_tag 'self_notified', 1, @user.pref.self_notified? %>
</div>
</div>
</div>

@ -26,19 +26,4 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title(t(:label_my_account), I18n.t('activerecord.attributes.user.mail_notification')) -%>
<% breadcrumb_paths(t(:label_my_account), I18n.t('activerecord.attributes.user.mail_notification')) %>
<%= toolbar title: I18n.t('activerecord.attributes.user.mail_notification') %>
<%= labelled_tabular_form_for @user,
as: :user,
url: { action: 'update_mail_notifications' },
lang: current_language,
html: { id: 'my_account_form', class: 'form -wide-labels' } do %>
<section class="form--section">
<%= render partial: 'users/mail_notifications' %>
</section>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>
<openproject-base></openproject-base>

@ -1,4 +0,0 @@
<section class="form--section">
<h3 class="form--section-title"><%= User.human_attribute_name(:mail_notification) %></h3>
<%= render partial: 'users/mail_notifications' %>
</section>

@ -0,0 +1,38 @@
#-- 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::NotificationJob < ApplicationJob
queue_with_priority :notification
def perform(notification, sender)
# TODO: implement properly
# move sender to database
Mails::WorkPackageJob
.perform_now(notification.resource, notification.recipient_id, sender.id)
end
end

@ -53,25 +53,22 @@ class Mails::WatcherJob < Mails::DeliverJob
def notify_about_watcher_changed?
return false if notify_about_self_watching?
return false unless UserMailer.perform_deliveries
case watcher.user.mail_notification
when 'only_my_events'
true
when 'selected'
watching_selected_includes_project?
else
watcher.user.notify_about?(watcher.watchable)
end
settings = watcher
.user
.notification_settings
.applicable(watcher.watchable.project)
.mail
.first
settings.watched || settings.all
end
def notify_about_self_watching?
watcher.user == sender && !sender.pref.self_notified?
end
def watching_selected_includes_project?
watcher.user.notified_projects_ids.include?(watcher.watchable.project_id)
end
def action
raise NotImplementedError, 'subclass responsibility'
end

@ -0,0 +1,53 @@
#-- 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
class CleanupJob < ::Cron::CronJob
DEFAULT_RETENTION ||= 30
# runs at 2:22 nightly
self.cron_expression = '22 2 * * *'
def perform
Notification
.where('updated_at < ?', oldest_notification_retention_time)
.delete_all
end
private
def oldest_notification_retention_time
days_ago = Setting.notification_retention_period_days.to_i
days_ago = DEFAULT_RETENTION if days_ago <= 0
Time.zone.today - days_ago.days
end
end
end

@ -134,10 +134,10 @@ Redmine::MenuManager.map :my_menu do |menu|
{ controller: '/my', action: 'access_token' },
caption: I18n.t('my_account.access_tokens.access_token'),
icon: 'icon2 icon-key'
menu.push :mail_notifications,
{ controller: '/my', action: 'mail_notifications' },
caption: I18n.t('activerecord.attributes.user.mail_notification'),
icon: 'icon2 icon-news'
menu.push :notifications,
{ controller: '/my', action: 'notifications' },
caption: I18n.t('js.notifications.settings.title'),
icon: 'icon2 icon-bell'
menu.push :delete_account, :delete_my_account_info_path,
caption: I18n.t('account.delete'),

@ -32,8 +32,11 @@ OpenProject::Notifications.subscribe(OpenProject::Events::JOURNAL_CREATED) do |p
Notifications::JournalNotificationService.call(payload[:journal], payload[:send_notification])
end
# The aggregated journal ready listeners in effect are run inside a delayed job
# since they are called by (in effect) by a background job triggered within the
# Notifications::JournalNotificationService
OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WORK_PACKAGE_JOURNAL_READY) do |payload|
Notifications::JournalWpMailService.call(payload[:journal], payload[:send_mail])
Notifications::JournalWpNotificationService.call(payload[:journal], payload[:send_mail])
end
OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WIKI_JOURNAL_READY) do |payload|
@ -41,6 +44,8 @@ OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WIKI_JOURNA
end
OpenProject::Notifications.subscribe(OpenProject::Events::WATCHER_ADDED) do |payload|
next unless payload[:send_notifications]
Mails::WatcherAddedJob
.perform_later(payload[:watcher],
payload[:watcher_setter])

@ -535,7 +535,6 @@ en:
force_password_change: "Enforce password change on next login"
language: "Language"
last_login_on: "Last login"
mail_notification: "Email notifications"
new_password: "New password"
password_confirmation: "Confirmation"
consented_at: "Consented at"
@ -666,6 +665,13 @@ en:
overlaps: 'overlap.'
outside: 'is outside of the grid.'
end_before_start: 'end value needs to be larger than the start value.'
notifications:
at_least_one_channel: 'At least one channel for sending notifications needs to be specified.'
attributes:
read_ian:
read_on_creation: 'cannot be set to true on notification creation.'
read_email:
read_on_creation: 'cannot be set to true on notification creation.'
parse_schema_filter_params_service:
attributes:
base:
@ -1347,6 +1353,12 @@ en:
expiration: "Expires"
indefinite_expiration: "Never"
notifications:
work_packages:
subject:
assigned: "You have been assigned to %{work_package}"
mentioned: "You have been mentioned in %{work_package}"
label_accessibility: "Accessibility"
label_account: "Account"
label_active: "Active"
@ -2360,7 +2372,6 @@ en:
setting_first_week_of_year: "First week in year contains"
setting_date_format: "Date format"
setting_default_language: "Default language"
setting_default_notification_option: "Default notification option"
setting_default_projects_modules: "Default enabled modules for new projects"
setting_default_projects_public: "New projects are public by default"
setting_diff_max_lines_displayed: "Max number of diff lines displayed"
@ -2370,6 +2381,7 @@ en:
setting_email_login: "Use email as login"
setting_enabled_scm: "Enabled SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Event retention period"
setting_feeds_enabled: "Enable Feeds"
setting_feeds_limit: "Feed content limit"
setting_file_max_size_displayed: "Max size of text files displayed inline"
@ -2439,6 +2451,10 @@ en:
passwords: "Passwords"
session: "Session"
brute_force_prevention: "Automated user blocking"
notifications:
retention_text: >
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.
display:
first_date_of_week_and_year_set: >
If either options "%{day_of_week_setting_name}" or "%{first_week_setting_name}" are set,
@ -2813,10 +2829,6 @@ en:
password_change_unsupported: Change of password is not supported.
registered: "registered"
reset_failed_logins: "Reset failed logins"
settings:
mail_notifications: "Send email notifications"
mail_project_explanaition: "For unselected projects, you will only receive notifications about things you watch or you're involved in (e.g. work packages you're the author or assignee of)."
mail_self_notified: "I want to be notified of changes that I make myself"
status_user_and_brute_force: "%{user} and %{brute_force}"
status_change: "Status change"
text_change_disabled_for_provider_login: "The name is set by your login provider and can thus not be changed."

@ -558,6 +558,21 @@ en:
timeline_button: 'You can activate the <b>Gantt chart</b> to create a timeline for your project.'
timeline: 'Here you can edit your project plan. Create new phases, milestones, and add dependencies. All team members can see and update the latest plan at any time.'
notifications:
channel: "Channel"
channels:
in_app: "In-app"
mail: "Email"
settings:
default_all_projects: 'Default for all projects'
involved: 'I am involved'
mentioned: 'I was mentioned'
watched: 'I am watching'
all: 'All events'
add: 'Add setting for project'
remove_projects: 'Remove notifications for projects'
title: "Notification settings"
self_notify: "I want to be notified of changes that I make myself"
password_confirmation:
field_description: 'You need to enter your account password to confirm this change.'

@ -541,11 +541,10 @@ OpenProject::Application.routes.draw do
get '/my/account', action: 'account'
get '/my/settings', action: 'settings'
get '/my/mail_notifications', action: 'mail_notifications'
get '/my/notifications', action: 'notifications'
patch '/my/account', action: 'update_account'
patch '/my/settings', action: 'update_settings'
patch '/my/mail_notifications', action: 'update_mail_notifications'
post '/my/generate_rss_key', action: 'generate_rss_key'
post '/my/generate_api_key', action: 'generate_api_key'

@ -326,8 +326,6 @@ session_ttl_enabled:
session_ttl:
format: int
default: 120
default_notification_option:
default: 'only_my_events'
emails_header:
serialized: true
default:
@ -381,3 +379,6 @@ apiv3_cors_enabled:
apiv3_cors_origins:
serialized: true
default: []
notification_retention_period_days:
default: 30
format: int

@ -0,0 +1,19 @@
class CreateNotifications < ActiveRecord::Migration[6.1]
def change
create_table :notifications do |t|
t.text :subject
t.boolean :read_ian, default: false, index: true
t.boolean :read_email, default: false, index: true
t.integer :reason, limit: 1
t.references :recipient, null: false, index: true, foreign_key: { to_table: :users }
t.references :actor, null: true, foreign_key: { to_table: :users }
t.references :resource, polymorphic: true, null: false
t.references :project
t.references :journal, index: false
t.timestamps
end
end
end

@ -0,0 +1,12 @@
class AuthorsAsWatchers < ActiveRecord::Migration[6.1]
def up
WorkPackage
.includes(:author, :project)
.find_each do |work_package|
Watcher.create(user: work_package.author, watchable: work_package)
end
end
# No down since we cannot distinguish between watchers that existed before
# and the ones that where created by the migration.
end

@ -0,0 +1,123 @@
class AddNotificationSettings < ActiveRecord::Migration[6.1]
def up
create_table :notification_settings do |t|
t.belongs_to :project, null: true, index: true, foreign_key: true
t.belongs_to :user, null: false, index: true, foreign_key: true
t.integer :channel, limit: 1
t.boolean :watched, default: false
t.boolean :involved, default: false
t.boolean :mentioned, default: false
t.boolean :all, default: false
t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
t.index %i[user_id channel],
unique: true,
where: "project_id IS NULL",
name: 'index_notification_settings_unique_project_null'
t.index %i[user_id project_id channel],
unique: true,
where: "project_id IS NOT NULL",
name: 'index_notification_settings_unique_project'
end
insert_project_specific_channel
insert_default_mail_channel
insert_default_in_app_channel
remove_column :members, :mail_notification
end
def down
add_column :members, :mail_notification, :boolean, default: false, null: false
add_column :users, :mail_notification, :string, default: '', null: false
update_mail_notifications
drop_table :notification_settings
end
def insert_default_mail_channel
execute <<~SQL
INSERT INTO
notification_settings
(user_id,
channel,
watched,
involved,
mentioned,
"all")
SELECT
id,
1,
mail_notification = 'only_my_events',
mail_notification = 'only_my_events' OR mail_notification = 'only_assigned',
NOT mail_notification = 'all' AND NOT mail_notification = 'NONE',
mail_notification = 'all'
FROM
users
WHERE
mail_notification IS NOT NULL
SQL
end
def insert_project_specific_channel
execute <<~SQL
INSERT INTO
notification_settings
(project_id,
user_id,
channel,
"all")
SELECT
project_id,
user_id,
1,
true
FROM
members
WHERE
mail_notification
SQL
end
def insert_default_in_app_channel
execute <<~SQL
INSERT INTO
notification_settings
(user_id,
channel,
involved,
mentioned)
SELECT
id,
0,
true,
true
FROM
users
SQL
end
def update_mail_notifications
# We cannot reconstruct the settings completely
execute <<~SQL
UPDATE
users
SET
mail_notification = CASE
WHEN notification_settings.all
THEN 'all'
WHEN notification_settings.watched
THEN 'only_my_events'
WHEN notification_settings.involved
THEN 'only_assigned'
ELSE 'none'
FROM
notification_settings
WHERE
notification_settings.user_id = users.id
SQL
end
end

@ -0,0 +1,78 @@
# Group Notifications
## View notification [/api/v3/notifications/{id}]
+ Model
+ Body
{
"_type": "Notification",
"_links": {
"self": {
"href": "/api/v3/notifications/1",
"title": "Work package abc was updated: You are now assignee"
},
"resource": {
"href": "/api/v3/activity/1",
"title": "quis numquam qui voluptatum quia praesentium blanditiis nisi"
},
"context": {
"href": "/api/v3/project/1",
"title": "quis numquam qui voluptatum quia praesentium blanditiis nisi"
},
"actor": {
"href": "/api/v3/users/1",
"title": "John Sheppard"
}
},
"id": 1,
"subject": "Work package abc was updated: You are now assignee"
"readIAN": false,
"readMail": false,
"reason": "mentioned",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z",
"_embedded:": {
"resource": {
"_type": "Activity::Comment",
"_links": {
"self": {
"href": "/api/v3/activity/1",
"title": "Priority changed from High to Low"
},
"workPackage": {
"href": "/api/v3/work_packages/1",
"title": "quis numquam qui voluptatum quia praesentium blanditiis nisi"
},
"user": {
"href": "/api/v3/users/1",
"title": "John Sheppard - admin"
}
},
"id": 1,
"details": [
{
"format": "markdown",
"raw": "Lorem ipsum dolor sit amet.",
"html": "<p>Lorem ipsum dolor sit amet.</p>"
}
],
"comment": {
"format": "markdown",
"raw": "Lorem ipsum dolor sit amet.",
"html": "<p>Lorem ipsum dolor sit amet.</p>"
},
"createdAt": "2014-05-21T08:51:20Z",
"version": 31
}
}
}
## View notification [GET]
+ Parameters
+ id (required, integer, `1`) ... Notification id
+ Response 200 (application/hal+json)
[View notification][]

@ -519,6 +519,7 @@ Returns a collection of projects. The collection can be filtered via query param
+ type_id: based on the types active in a project.
+ user_action: based on the actions (see [Actions](#actions)) the current user has in the project.
+ id: based on projects' id.
+ visible: based on the visibility for the user (id) provided as the filter value. This filter is useful for admins to identify the projects visible to a user.
There might also be additional filters based on the custom fields that have been configured.
+ sortBy (optional, string, `[["id", "asc"]]`) ... JSON specifying sort criteria.
Currently supported orders are:

@ -79,6 +79,7 @@ import { globalDynamicComponents } from "core-app/core/setup/global-dynamic-comp
import { HookService } from "core-app/features/plugins/hook-service";
import { OpenprojectPluginsModule } from "core-app/features/plugins/openproject-plugins.module";
import { LinkedPluginsModule } from "core-app/features/plugins/linked-plugins.module";
import { OpenProjectInAppNotificationsModule } from "core-app/features/in-app-notifications/in-app-notifications.module";
@NgModule({
imports: [
@ -152,6 +153,9 @@ import { LinkedPluginsModule } from "core-app/features/plugins/linked-plugins.mo
// Tabs
OpenprojectTabsModule,
// Notifications
OpenProjectInAppNotificationsModule,
],
providers: [
{ provide: States, useValue: new States() },

@ -58,6 +58,7 @@ import Project = ts.server.Project;
import { Apiv3PlaceholderUsersPaths } from "core-app/core/apiv3/endpoints/placeholder-users/apiv3-placeholder-users-paths";
import { Apiv3GroupsPaths } from "core-app/core/apiv3/endpoints/groups/apiv3-groups-paths";
import { HalResource } from "core-app/features/hal/resources/hal-resource";
import { Apiv3NotificationsPaths } from "core-app/core/apiv3/endpoints/notifications/apiv3-notifications-paths";
@Injectable({ providedIn: 'root' })
export class APIV3Service {
@ -70,6 +71,9 @@ export class APIV3Service {
// /api/v3/documents
public readonly documents = this.apiV3CollectionEndpoint('documents');
// /api/v3/notifications
public readonly notifications = this.apiV3CustomEndpoint(Apiv3NotificationsPaths);
// /api/v3/grids
public readonly grids = this.apiV3CustomEndpoint(Apiv3GridsPaths);

@ -0,0 +1,63 @@
//-- 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.
//++
import { APIv3GettableResource } from "core-app/core/apiv3/paths/apiv3-resource";
import { Observable } from "rxjs";
import { InAppNotification } from "core-app/features/in-app-notifications/store/in-app-notification.model";
import { HttpClient } from "@angular/common/http";
import { InjectField } from "core-app/shared/helpers/angular/inject-field.decorator";
export class Apiv3NotificationPaths extends APIv3GettableResource<InAppNotification> {
@InjectField() http:HttpClient;
public markRead():Observable<unknown> {
return this
.http
.post(
this.path + '/readIAN',
{},
{
withCredentials: true,
responseType: 'json'
}
);
}
public markUnread():Observable<unknown> {
return this
.http
.post(
this.path + '/unreadIAN',
{},
{
withCredentials: true,
responseType: 'json'
}
);
}
}

@ -0,0 +1,69 @@
//-- 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.
//++
import { APIv3ResourceCollection } from "core-app/core/apiv3/paths/apiv3-resource";
import { APIV3Service } from "core-app/core/apiv3/api-v3.service";
import { Observable } from "rxjs";
import { Apiv3ListParameters, listParamsString } from "core-app/core/apiv3/paths/apiv3-list-resource.interface";
import { InAppNotification } from "core-app/features/in-app-notifications/store/in-app-notification.model";
import { Apiv3NotificationPaths } from "core-app/core/apiv3/endpoints/notifications/apiv3-notification-paths";
import { InjectField } from "core-app/shared/helpers/angular/inject-field.decorator";
import { HttpClient } from "@angular/common/http";
import { IHALCollection } from "core-app/core/apiv3/types/hal-collection.type";
export class Apiv3NotificationsPaths
extends APIv3ResourceCollection<InAppNotification, Apiv3NotificationPaths> {
@InjectField() http:HttpClient;
constructor(protected apiRoot:APIV3Service,
protected basePath:string) {
super(apiRoot, basePath, 'notifications', Apiv3NotificationPaths);
}
/**
* Load a list of events with a given list parameter filter
* @param params
*/
public list(params?:Apiv3ListParameters):Observable<IHALCollection<InAppNotification>> {
return this
.http
.get<IHALCollection<InAppNotification>>(this.path + listParamsString(params));
}
/**
* Load unread events
*/
public unread(additional?:Apiv3ListParameters):Observable<IHALCollection<InAppNotification>> {
const params:Apiv3ListParameters = {
...additional,
filters: [["readIAN", "=", false]]
};
return this.list(params);
}
}

@ -27,14 +27,16 @@
//++
import { UserResource } from "core-app/features/hal/resources/user-resource";
import { MultiInputState } from "reactivestates";
import { CachableAPIV3Resource } from "core-app/core/apiv3/cache/cachable-apiv3-resource";
import { StateCacheService } from "core-app/core/apiv3/cache/state-cache.service";
import { Apiv3UserPreferencesPaths } from "core-app/core/apiv3/endpoints/users/apiv3-user-preferences-paths";
export class APIv3UserPaths extends CachableAPIV3Resource<UserResource> {
readonly avatar = this.subResource('avatar');
readonly preferences = this.subResource('preferences', Apiv3UserPreferencesPaths);
protected createCache():StateCacheService<UserResource> {
return new StateCacheService<UserResource>(this.states.users);
}

@ -0,0 +1,62 @@
//-- 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.
//++
import { APIv3ResourcePath } from "core-app/core/apiv3/paths/apiv3-resource";
import { Observable } from "rxjs";
import { InjectField } from "core-app/shared/helpers/angular/inject-field.decorator";
import { HttpClient } from "@angular/common/http";
import { UserPreferencesModel } from "core-app/features/user-preferences/state/user-preferences.model";
export class Apiv3UserPreferencesPaths extends APIv3ResourcePath<UserPreferencesModel> {
@InjectField() http:HttpClient;
/**
* Perform a request to the backend to load preferences
*/
public get():Observable<UserPreferencesModel> {
return this
.http
.get<UserPreferencesModel>(
this.path,
);
}
/**
* Perform a request to update preferences
*/
public patch(payload:Partial<UserPreferencesModel>):Observable<UserPreferencesModel> {
return this
.http
.patch<UserPreferencesModel>(
this.path,
payload,
{ withCredentials: true, responseType: 'json' }
);
}
}

@ -42,7 +42,7 @@ export class Apiv3UsersPaths extends APIv3ResourceCollection<UserResource, APIv3
// Static paths
// /api/v3/users/me
public readonly me = this.path + '/me';
public readonly me = this.subResource('me', APIv3UserPaths);
// /api/v3/users/form
public readonly form = this.subResource('form', APIv3FormResource);

@ -30,7 +30,7 @@ import { CollectionResource } from "core-app/features/hal/resources/collection-r
import { Observable } from "rxjs";
import { ApiV3FilterBuilder, FilterOperator } from "core-app/shared/helpers/api-v3/api-v3-filter-builder";
export type ApiV3ListFilter = [string, FilterOperator, string[]];
export type ApiV3ListFilter = [string, FilterOperator, boolean|string[]];
export interface Apiv3ListParameters {
filters?:ApiV3ListFilter[];

@ -6,6 +6,7 @@ import { Observable } from "rxjs";
import { APIV3Service } from "core-app/core/apiv3/api-v3.service";
import { ApiV3FilterBuilder } from "core-app/shared/helpers/api-v3/api-v3-filter-builder";
import { HalResource } from "core-app/features/hal/resources/hal-resource";
import { CollectionResource } from "core-app/features/hal/resources/collection-resource";
export class APIv3ResourcePath<T = HalResource> extends SimpleResource {
readonly injector = this.apiRoot.injector;
@ -98,7 +99,7 @@ export class APIv3ResourceCollection<V, T extends APIv3GettableResource<V>> exte
* @param filters filter object to filter with
* @param params additional URL params to append
*/
public filtered<R = APIv3GettableResource<V>>(filters:ApiV3FilterBuilder, params:{ [key:string]:string } = {}, resourceClass?:Constructor<R>):R {
public filtered<R = APIv3GettableResource<CollectionResource<V>>>(filters:ApiV3FilterBuilder, params:{ [key:string]:string } = {}, resourceClass?:Constructor<R>):R {
return this.subResource<R>('?' + filters.toParams(params), resourceClass) as R;
}

@ -0,0 +1,10 @@
export interface IHALCollection<T> {
_type:'Collection';
count:number;
total:number;
pageSize:number;
offset:number;
_embedded:{
elements:T[];
}
}

@ -34,6 +34,7 @@ import { FirstRouteService } from "core-app/core/routing/first-route-service";
import { Ng2StateDeclaration, StatesModule } from "@uirouter/angular";
import { appBaseSelector, ApplicationBaseComponent } from "core-app/core/routing/base/application-base.component";
import { BackRoutingService } from "core-app/features/work-packages/components/back-routing/back-routing.service";
import { MY_ACCOUNT_LAZY_ROUTES } from "core-app/features/user-preferences/user-preferences.lazy-routes";
export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
{
@ -104,6 +105,7 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
url: '/copy',
loadChildren: () => import('../../features/projects/openproject-projects.module').then(m => m.OpenprojectProjectsModule)
},
...MY_ACCOUNT_LAZY_ROUTES
];
/**

@ -12,7 +12,10 @@ import {
ZenModeButtonComponent,
zenModeComponentSelector
} from "core-app/features/work-packages/components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component";
import { AttachmentsComponent, attachmentsSelector } from "core-app/shared/components/attachments/attachments.component";
import {
AttachmentsComponent,
attachmentsSelector
} from "core-app/shared/components/attachments/attachments.component";
import {
UserAutocompleterComponent,
usersAutocompleterSelector
@ -42,7 +45,7 @@ import {
PersistentToggleComponent,
persistentToggleSelector
} from "core-app/shared/components/persistent-toggle/persistent-toggle.component";
import {OpPrincipalComponent, principalSelector} from "core-app/shared/components/principal/principal.component";
import { OpPrincipalComponent, principalSelector } from "core-app/shared/components/principal/principal.component";
import {
HideSectionLinkComponent,
hideSectionLinkSelector
@ -127,7 +130,10 @@ import {
quickInfoMacroSelector,
WorkPackageQuickinfoMacroComponent
} from "core-app/shared/components/fields/macros/work-package-quickinfo-macro.component";
import { SlideToggleComponent, slideToggleSelector } from "core-app/shared/components/slide-toggle/slide-toggle.component";
import {
SlideToggleComponent,
slideToggleSelector
} from "core-app/shared/components/slide-toggle/slide-toggle.component";
import { BackupComponent, backupSelector } from "core-app/core/setup/globals/components/admin/backup.component";
import {
EnterpriseBaseComponent,
@ -162,6 +168,10 @@ import {
EditableQueryPropsComponent,
editableQueryPropsSelector,
} from "core-app/features/admin/editable-query-props/editable-query-props.component";
import {
InAppNotificationBellComponent,
opInAppNotificationBellSelector
} from "core-app/features/in-app-notifications/bell/in-app-notification-bell.component";
export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: appBaseSelector, cls: ApplicationBaseComponent },
@ -209,7 +219,8 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: quickInfoMacroSelector, cls: WorkPackageQuickinfoMacroComponent, embeddable: true },
{ selector: editableQueryPropsSelector, cls: EditableQueryPropsComponent },
{ selector: slideToggleSelector, cls: SlideToggleComponent },
{ selector: backupSelector, cls: BackupComponent }
{ selector: backupSelector, cls: BackupComponent },
{ selector: opInAppNotificationBellSelector, cls: InAppNotificationBellComponent }
];

@ -42,7 +42,7 @@ export interface HalResourceClass<T extends HalResource = HalResource> {
$halType:string):T;
}
export type HalSourceLink = { href:string|null };
export type HalSourceLink = { href:string|null, title?:string };
export type HalSourceLinks = {
[key:string]:HalSourceLink

@ -0,0 +1,16 @@
<button
type="button"
class="op-ian-bell op-app-menu--item-action"
(click)="openCenter($event)"
>
<op-icon icon-classes="icon-bell">
</op-icon>
<ng-container *ngIf="(unreadCount$ | async) as unreadCount">
<span
*ngIf="unreadCount > 0"
class="op-ian-bell--indicator"
data-qa-selector="op-ian-notifications-count"
[textContent]="unreadCount">
</span>
</ng-container>
</button>

@ -0,0 +1,18 @@
@import "src/assets/sass/helpers"
.op-ian-bell
position: relative
&--indicator
position: absolute
top: 10px
right: 5px
display: flex
justify-content: center
align-items: center
height: 16px
width: 16px
border-radius: 0.5rem
font-size: 0.7rem
background: darkred
font-weight: bold

@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { InAppNotificationsQuery } from "core-app/features/in-app-notifications/store/in-app-notifications.query";
import { InAppNotificationsService } from "core-app/features/in-app-notifications/store/in-app-notifications.service";
import { OpModalService } from "core-app/shared/components/modal/modal.service";
import { InAppNotificationCenterComponent } from "core-app/features/in-app-notifications/center/in-app-notification-center.component";
import { merge, timer } from "rxjs";
import { filter, switchMap } from "rxjs/operators";
import { ActiveWindowService } from "core-app/core/active-window/active-window.service";
export const opInAppNotificationBellSelector = 'op-in-app-notification-bell';
const POLLING_INTERVAL = 10000;
@Component({
selector: opInAppNotificationBellSelector,
templateUrl: './in-app-notification-bell.component.html',
styleUrls: ['./in-app-notification-bell.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InAppNotificationBellComponent {
polling$ = timer(10, POLLING_INTERVAL)
.pipe(
filter(() => this.activeWindow.isActive),
switchMap(() => this.inAppService.count$()),
);
unreadCount$ = merge(
this.polling$,
this.inAppQuery.unreadCount$
);
constructor(readonly inAppQuery:InAppNotificationsQuery,
readonly inAppService:InAppNotificationsService,
readonly activeWindow:ActiveWindowService,
readonly modalService:OpModalService) {
}
openCenter(event:MouseEvent) {
this.modalService.show(InAppNotificationCenterComponent, 'global');
event.preventDefault();
}
}

@ -0,0 +1,62 @@
<div
class="op-modal op-modal_fullscreen"
>
<op-modal-header (close)="closeMe($event)">{{text.title}}</op-modal-header>
<div class="op-modal--body op-ian-center">
<div class="toolbar-container">
<div class="toolbar">
<div class="title-container">
</div>
<ul class="toolbar-items">
<li class="toolbar-item">
<button
type="button"
class="button"
(click)="markAllRead()"
>
<op-icon icon-classes="button--icon icon-add">
</op-icon>
<span
class="button--text"
[textContent]="text.mark_all_read"
>
</span>
</button>
</li>
</ul>
</div>
</div>
<ul
*ngIf="hasUnreadItems$ | async; else noResults"
class="op-ian-center--entries"
>
<op-in-app-notification-entry
*ngFor="let item of unreadItems$ | async"
class="op-ian-center--entry"
[notification]="item"
></op-in-app-notification-entry>
</ul>
<ng-template #noResults>
<div class="generic-table--no-results-container">
<span
class="generic-table--no-results-title"
>
<op-icon icon-classes="icon-info1"></op-icon>
{{ text.no_results }}
</span>
</div>
</ng-template>
</div>
<div class="op-modal--footer">
<button
type="button"
class="button"
(click)="closeMe($event)"
[textContent]="text.button_close"
[attr.title]="text.button_close"
></button>
</div>
</div>

@ -0,0 +1,13 @@
.op-ian-center
//grid-template-columns: 1fr 3fr
//grid-template-areas: "filters entries"
&--filters
grid-area: filters
&--entries
grid-area: entries
list-style: none
&--entry
display: list-item

@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnInit } from '@angular/core';
import { OpModalComponent } from "core-app/shared/components/modal/modal.component";
import { OpModalLocalsToken } from "core-app/shared/components/modal/modal.service";
import { OpModalLocalsMap } from "core-app/shared/components/modal/modal.types";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { InAppNotificationsQuery } from "core-app/features/in-app-notifications/store/in-app-notifications.query";
import { InAppNotificationsService } from "core-app/features/in-app-notifications/store/in-app-notifications.service";
import { map } from "rxjs/operators";
@Component({
selector: 'op-in-app-notification-center',
templateUrl: './in-app-notification-center.component.html',
styleUrls: ['./in-app-notification-center.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InAppNotificationCenterComponent extends OpModalComponent implements OnInit {
hasUnreadItems$ = this.ianQuery.hasUnread$;
unreadItems$ = this.ianQuery.unread$;
text = {
title: 'Notifications',
mark_all_read: 'Mark all as read',
button_close: this.I18n.t('js.button_close'),
no_results: this.I18n.t('js.notice_no_results_to_display'),
};
constructor(
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly elementRef:ElementRef,
readonly I18n:I18nService,
readonly ianService:InAppNotificationsService,
readonly ianQuery:InAppNotificationsQuery,
) {
super(locals, cdRef, elementRef);
}
ngOnInit():void {
this.ianService.get();
}
markAllRead() {
this.ianService.markAllRead();
this.closeMe();
}
}

@ -0,0 +1,48 @@
<div
class="op-ian-item"
attr.data-qa-selector="op-ian-notification-item-{{notification.id}}"
>
<button
type="button"
class="op-ian-item--row"
(click)="toggleDetails()"
>
<span
class="op-ian-item--indicator"
[class.op-ian-item--indicator_read]="!!notification.read"
></span>
<div class="op-ian-item--message">
<a
*ngIf="notification._links.context"
class="op-ian-item--project"
[href]="notification._links.context.href"
[textContent]="notification._links.context.title"
target="_blank"
></a>
<span
class="op-ian-item--title"
[textContent]="notification.subject"
></span>
</div>
<div
class="op-ian-item--reason"
[textContent]="notification.reason"
></div>
<div
class="op-ian-item--date"
[textContent]="notification.date"
></div>
</button>
<div
*ngIf="expanded"
class="op-ian-item--details"
>
<ul *ngIf="notification.details">
<li
*ngFor="let detail of notification.details"
[textContent]="detail"
>
</li>
</ul>
</div>
</div>

@ -0,0 +1,40 @@
@import "src/assets/sass/helpers"
.op-ian-item
background: #F9F9F9
margin: 10px 0
padding: 5px 10px
font-size: 1.2rem
&--row
@include unset-button-styles
width: 100%
display: flex
align-items: center
&--indicator
width: 10px
height: 10px
margin-right: 0.5rem
display: block
background: var(--primary-color)
border-radius: 50%
&_read
background: #AFAFAF
&--message
flex: 1
display: flex
flex-direction: column
line-height: 1.1rem
&--project
font-size: 0.8rem
color: var(--gray-dark)
&--reason,
&--date
width: 20%
flex-grow: 0
text-align: right

@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { InAppNotification } from "core-app/features/in-app-notifications/store/in-app-notification.model";
@Component({
selector: 'op-in-app-notification-entry',
templateUrl: './in-app-notification-entry.component.html',
styleUrls: ['./in-app-notification-entry.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InAppNotificationEntryComponent {
@Input() notification:InAppNotification;
expanded = false;
constructor() { }
toggleDetails() {
this.expanded = !this.expanded;
this.notification = { ...this.notification, read: true };
}
}

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IconModule } from "core-app/shared/components/icon/icon.module";
import { InAppNotificationBellComponent } from "core-app/features/in-app-notifications/bell/in-app-notification-bell.component";
import { InAppNotificationCenterComponent } from './center/in-app-notification-center.component';
import { OpenprojectModalModule } from "core-app/shared/components/modal/modal.module";
import { InAppNotificationEntryComponent } from "core-app/features/in-app-notifications/entry/in-app-notification-entry.component";
@NgModule({
declarations: [
InAppNotificationBellComponent,
InAppNotificationCenterComponent,
InAppNotificationEntryComponent,
],
imports: [
CommonModule,
IconModule,
OpenprojectModalModule,
]
})
export class OpenProjectInAppNotificationsModule { }

@ -0,0 +1,21 @@
import { ID } from "@datorama/akita";
export interface HalResourceLink {
href:string;
title:string;
}
export interface InAppNotification {
id:ID;
subject:string;
date:string;
reason:string;
read?:boolean;
details?:string[];
_links:{
context?:HalResourceLink,
resource:HalResourceLink,
};
}

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { QueryEntity } from '@datorama/akita';
import { InAppNotificationsStore, InAppNotificationsState } from './in-app-notifications.store';
import { map } from "rxjs/operators";
@Injectable({ providedIn: 'root' })
export class InAppNotificationsQuery extends QueryEntity<InAppNotificationsState> {
/** Get the number of unread items */
unreadCount$ = this.select('count');
/** Do we have any unread items? */
hasUnread$ = this.unreadCount$.pipe(map(count => count > 0));
/** Get the unread items */
unread$ = this.selectAll({
filterBy: ({ read }) => !read
});
constructor(protected store:InAppNotificationsStore) {
super(store);
}
}

@ -0,0 +1,91 @@
import { Injectable } from '@angular/core';
import { applyTransaction, ID, transaction, withTransaction } from '@datorama/akita';
import { InAppNotification } from './in-app-notification.model';
import { InAppNotificationsStore } from './in-app-notifications.store';
import { forkJoin, Observable, timer } from "rxjs";
import { APIV3Service } from "core-app/core/apiv3/api-v3.service";
import { map, switchMap, tap } from "rxjs/operators";
import { NotificationsService } from "core-app/shared/components/notifications/notifications.service";
import { InAppNotificationsQuery } from "core-app/features/in-app-notifications/store/in-app-notifications.query";
import { take } from "rxjs/internal/operators/take";
import apply = Reflect.apply;
@Injectable({ providedIn: 'root' })
export class InAppNotificationsService {
constructor(
private store:InAppNotificationsStore,
private query:InAppNotificationsQuery,
private apiV3Service:APIV3Service,
private notifications:NotificationsService,
) {
}
get():void {
this.store.setLoading(true);
this
.apiV3Service
.notifications
.unread()
.subscribe(
events => {
applyTransaction(() => {
this.store.set(events._embedded.elements);
this.store.update({ count: events.total });
});
},
error => {
this.notifications.addError(error);
},
)
.add(
() => this.store.setLoading(false)
);
}
count$():Observable<number> {
return this
.apiV3Service
.notifications
.unread({ pageSize: 0 })
.pipe(
map(events => events.total),
tap(count => this.store.update({ count }))
);
}
@transaction()
add(inAppNotification:InAppNotification):void {
this.store.add(inAppNotification);
this.store.update(state => ({ ...state, count: state.count + 1}));
}
update(id:ID, inAppNotification:Partial<InAppNotification>):void {
this.store.update(id, inAppNotification);
}
@transaction()
remove(id:ID):void {
this.store.remove(id);
this.store.update(state => ({ ...state, count: state.count + 1}));
}
markAllRead():void {
this.query
.unread$
.pipe(
take(1),
switchMap(events =>
forkJoin(
events.map(event => this.apiV3Service.notifications.id(event.id).markRead())
)
)
)
.subscribe(() => {
applyTransaction(() => {
this.store.update(null, { read: true });
this.store.update({ count: 0 });
});
});
}
}

@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { InAppNotification } from './in-app-notification.model';
export interface InAppNotificationsState extends EntityState<InAppNotification> {
count:number;
}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'in-app-notifications' })
export class InAppNotificationsStore extends EntityStore<InAppNotificationsState> {
constructor() {
super();
}
}

@ -16,7 +16,7 @@ import { HalResource } from "core-app/features/hal/resources/hal-resource";
export interface ProjectTemplateOption {
href:string|null;
title:string;
name:string;
}
@Component({

@ -0,0 +1,23 @@
<div class="wp-inline-create-button">
<button
*ngIf="!active"
(click)="active = true"
class="wp-inline-create--add-link"
type="button"
>
<op-icon icon-classes="icon-context icon-add"></op-icon>
<span [textContent]="text.add_setting"></span>
</button>
<op-autocompleter
*ngIf="active"
[placeholder]="text.please_select"
(change)="selectProject($event)"
(keydown.escape)="active = false"
[filters]="autocompleterOptions.filters"
[resource]="autocompleterOptions.resource"
[getOptionsFn]="autocompleterOptions.getOptionsFn"
appendTo="body"
>
</op-autocompleter>
</div>

@ -0,0 +1,69 @@
import { EventEmitter, Component, OnInit, ChangeDetectionStrategy, Output, Input } from '@angular/core';
import { I18nService } from "core-app/core/i18n/i18n.service";
import { Observable, of } from "rxjs";
import { map, tap } from "rxjs/operators";
import { APIV3Service } from "core-app/core/apiv3/api-v3.service";
import { ApiV3FilterBuilder } from "core-app/shared/helpers/api-v3/api-v3-filter-builder";
import { HalSourceLink } from "core-app/features/hal/resources/hal-resource";
export interface NotificationSettingProjectOption {
name:string;
href:string;
}
@Component({
selector: 'op-notification-setting-inline-create',
templateUrl: './notification-setting-inline-create.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationSettingInlineCreateComponent {
@Input() userId:string;
@Output() onSelect = new EventEmitter<HalSourceLink>();
/** Active inline-create mode */
active = false;
text = {
add_setting: this.I18n.t('js.notifications.settings.add'),
please_select: this.I18n.t('js.placeholders.selection'),
};
public autocompleterOptions = {
filters:[],
resource:'default',
getOptionsFn: (query:string):Observable<any[]> => this.autocomplete(query)
};
constructor(
private I18n:I18nService,
private apiV3Service:APIV3Service,
) {
}
selectProject($event:NotificationSettingProjectOption) {
this.onSelect.emit({ title: $event.name, href: $event.href });
this.active = false;
}
private autocomplete(term:string):Observable<NotificationSettingProjectOption[]> {
if (!term) {
return of([]);
}
const filters = new ApiV3FilterBuilder()
.add('name_and_identifier', '~', [term])
.add('principal', '=', [this.userId]);
return this
.apiV3Service
.projects
.filtered(filters)
.get()
.pipe(
map((collection) => {
return collection.elements.map(project => ({ href: project.href!, name: project.name }));
})
);
}
}

@ -0,0 +1,34 @@
<op-notifications-settings-toolbar></op-notifications-settings-toolbar>
<op-notification-settings-table
*ngIf="userId"
[userId]="userId"
></op-notification-settings-table>
<fieldset class="form--fieldset form--section">
<legend class="form--fieldset-legend">
{{ text.advanced_settings }}
</legend>
<op-form-field
class="op-form-field_checkbox"
[noWrapLabel]="false"
[label]="text.self_notify"
>
<input
slot="input"
type="checkbox"
[checked]="(preferences$ | async).selfNotified"
(change)="updateNotified($event.target.checked)"
/>
</op-form-field>
</fieldset>
<div class="generic-table--action-buttons">
<button
class="button -highlight"
[textContent]="text.save"
(click)="saveChanges()"
>
</button>
</div>

@ -0,0 +1,61 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { I18nService } from "core-app/core/i18n/i18n.service";
import { CurrentUserService } from "core-app/core/current-user/current-user.service";
import { take } from "rxjs/internal/operators/take";
import { UIRouterGlobals } from "@uirouter/core";
import { UserPreferencesService } from "core-app/features/user-preferences/state/user-preferences.service";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
import { UserPreferencesQuery } from "core-app/features/user-preferences/state/user-preferences.query";
export const myNotificationsPageComponentSelector = 'op-notifications-page';
@Component({
selector: myNotificationsPageComponentSelector,
templateUrl: './notifications-settings-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationsSettingsPageComponent implements OnInit {
@Input() userId:string;
preferences$ = this.query.preferences$;
text = {
save: this.I18n.t('js.button_save'),
email: this.I18n.t('js.notifications.email'),
inApp: this.I18n.t('js.notifications.in_app'),
default_all_projects: this.I18n.t('js.notifications.settings.default_all_projects'),
advanced_settings: this.I18n.t('js.forms.advanced_settings'),
self_notify: this.I18n.t('js.notifications.settings.self_notify')
};
constructor(
private I18n:I18nService,
private stateService:UserPreferencesService,
private store:UserPreferencesStore,
private query:UserPreferencesQuery,
private currentUserService:CurrentUserService,
private uiRouterGlobals:UIRouterGlobals
) {
}
ngOnInit():void {
this.userId = this.userId || this.uiRouterGlobals.params.userId;
this
.currentUserService
.user$
.pipe(take(1))
.subscribe(user => {
this.userId = this.userId || user.id!;
this.stateService.get(this.userId);
});
}
public saveChanges():void {
const prefs = this.query.getValue();
this.stateService.update(this.userId, prefs);
}
updateNotified(checked:boolean) {
this.store.update({ selfNotified: checked });
}
}

@ -0,0 +1,66 @@
<td
*ngIf="first"
rowspan="2"
>
<span
*ngIf="setting._links.project.href; else defaultTitle"
[textContent]="setting._links.project.title"
></span>
<ng-template #defaultTitle>
<em [textContent]="text.default_all_projects"></em>
</ng-template>
</td>
<td [textContent]="text.channel(setting.channel)">
</td>
<td>
<input
type="checkbox"
[checked]="setting.involved || setting.all"
[disabled]="setting.all"
(change)="update({ involved: $event.target.checked })"
data-qa-notification-type="involved"
[attr.aria-label]="text.involved_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.mentioned || setting.all"
[disabled]="setting.all"
(change)="update({ mentioned: $event.target.checked })"
data-qa-notification-type="mentioned"
[attr.aria-label]="text.mentioned_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.watched || setting.all"
[disabled]="setting.all"
(change)="update({ watched: $event.target.checked })"
data-qa-notification-type="watched"
[attr.aria-label]="text.watched_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.all"
(change)="update({ all: $event.target.checked })"
data-qa-notification-type="all"
[attr.aria-label]="text.any_event_header"
/>
</td>
<td
*ngIf="first"
rowspan="2"
class="buttons"
>
<button
*ngIf="!global"
class="op-link"
(click)="remove()"
>
<op-icon icon-classes="icon-remove icon-no-color"></op-icon>
</button>
</td>

@ -0,0 +1,67 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { I18nService } from "core-app/core/i18n/i18n.service";
import { arrayUpdate } from "@datorama/akita";
import { NotificationSetting } from "core-app/features/user-preferences/state/notification-setting.model";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
@Component({
selector: '[op-notification-setting-row]',
templateUrl: './notification-setting-row.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationSettingRowComponent implements OnInit {
@Input() first = false;
@Input() setting:NotificationSetting;
/** Whether this setting is global */
global = false;
text = {
title: this.I18n.t('js.notifications.settings.title'),
save: this.I18n.t('js.button_save'),
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',
channel: (channel:string):string => this.I18n.t('js.notifications.channels.' + channel)
};
constructor(
private I18n:I18nService,
private store:UserPreferencesStore,
) {
}
ngOnInit() {
this.global = this.setting._links.project.href === null;
}
remove():void {
this.store.update(
({ notifications }) => ({
notifications: notifications.filter(notification =>
notification._links.project.href !== this.setting._links.project.href)
})
);
}
update(delta:Partial<NotificationSetting>) {
this.store.update(
({ notifications }) => ({
notifications: arrayUpdate(
notifications, this.matcherFn.bind(this), delta
)
})
);
}
private matcherFn(notification:NotificationSetting) {
return notification._links.project.href === this.setting._links.project.href &&
notification.channel === this.setting.channel;
}
}

@ -0,0 +1,76 @@
<div class="generic-table--container form--section">
<div class="generic-table--results-container">
<table class="generic-table">
<thead>
<tr>
<th>
<div class="generic-table--empty-header"></div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.channel_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.involved_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.mentioned_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.watched_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.any_event_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--empty-header"></div>
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let item of (groupedNotificationSettings$ | async) | keyvalue: projectOrder">
<ng-container *ngFor="let setting of item.value; let first = first; let last = last">
<tr
class="-no-highlighting"
op-notification-setting-row
[attr.data-qa-notification-project]="item.key"
[attr.data-qa-notification-channel]="setting.channel"
[first]="first"
[setting]="setting"
>
</tr>
<tr *ngIf="last"
class="op-notifications-settings-table--spacer">
<td colspan="7"></td>
</tr>
</ng-container>
</ng-container>
</tbody>
</table>
<op-notification-setting-inline-create
*ngIf="userId"
[userId]="userId"
(onSelect)="addRow($event)"
data-qa-selector="notification-setting-inline-create"
></op-notification-setting-inline-create>
</div>
</div>

@ -0,0 +1,6 @@
.op-notifications-settings-table
&--spacer
// The default table styles are a mess
td
padding: 0 !important

@ -0,0 +1,71 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { KeyValue } from "@angular/common";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { UserPreferencesService } from "core-app/features/user-preferences/state/user-preferences.service";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
import { UserPreferencesQuery } from "core-app/features/user-preferences/state/user-preferences.query";
import { CurrentUserService } from "core-app/core/current-user/current-user.service";
import { UIRouterGlobals } from "@uirouter/core";
import { HalSourceLink } from "core-app/features/hal/resources/hal-resource";
import {
buildNotificationSetting,
NotificationSetting
} from "core-app/features/user-preferences/state/notification-setting.model";
import { arrayAdd } from "@datorama/akita";
@Component({
selector: 'op-notification-settings-table',
templateUrl: './notification-settings-table.component.html',
styleUrls: ['./notification-settings-table.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationSettingsTableComponent {
@Input() userId:string;
groupedNotificationSettings$ = this.query.notificationsGroupedByProject$;
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'),
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'),
};
projectOrder = (a:KeyValue<string, unknown>, b:KeyValue<string, unknown>):number => {
if (a.key === 'global') {
return -1;
}
if (b.key === 'global') {
return 1;
}
return a.key.localeCompare(b.key);
};
constructor(
private I18n:I18nService,
private stateService:UserPreferencesService,
private store:UserPreferencesStore,
private query:UserPreferencesQuery
) {
}
addRow(project:HalSourceLink) {
const added:NotificationSetting[] = [
buildNotificationSetting(project, { channel: 'in_app' }),
buildNotificationSetting(project, { channel: 'mail' }),
];
this.store.update(
({ notifications }) => ({
notifications: arrayAdd(notifications, added)
})
);
}
}

@ -0,0 +1,22 @@
<div class="toolbar-container">
<div class="toolbar">
<div class="title-container">
<h2 [textContent]="text.title"></h2>
</div>
<ul class="toolbar-items">
<li class="toolbar-item">
<button
*ngIf="(projectSettings$ | async).length > 0"
class="button"
(click)="removeAll()"
>
<span
class="button--text"
[textContent]="text.remove_projects"
>
</span>
</button>
</li>
</ul>
</div>
</div>

@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { UserPreferencesQuery } from "core-app/features/user-preferences/state/user-preferences.query";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
@Component({
selector: 'op-notifications-settings-toolbar',
templateUrl: './notifications-settings-toolbar.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationsSettingsToolbarComponent {
projectSettings$ = this.query.projectNotifications$;
text = {
title: this.I18n.t('js.notifications.settings.title'),
remove_projects: this.I18n.t('js.notifications.settings.remove_projects'),
};
constructor(
private query:UserPreferencesQuery,
private store:UserPreferencesStore,
private I18n:I18nService,
) {
}
removeAll():void {
this.store.update(
({ notifications }) => ({
notifications: notifications.filter(notification => notification._links.project.href === null)
})
);
}
}

@ -0,0 +1,29 @@
import { HalSourceLink } from "core-app/features/hal/resources/hal-resource";
export type NotificationSettingChannel = 'mail'|'in_app';
export interface NotificationSetting {
_links:{ project:HalSourceLink };
channel:NotificationSettingChannel;
watched:boolean;
involved:boolean;
mentioned:boolean;
all:boolean;
}
export function buildNotificationSetting(project:null|HalSourceLink, params:Partial<NotificationSetting>):NotificationSetting {
return {
_links: {
project: {
href: project ? project.href : null,
title: project?.title
}
},
involved: true,
mentioned: true,
watched: false,
all: false,
channel: "in_app",
...params
};
}

@ -0,0 +1,11 @@
import { NotificationSetting } from "core-app/features/user-preferences/state/notification-setting.model";
export interface UserPreferencesModel {
autoHidePopups:boolean;
commentSortDescending:boolean;
hideMail:boolean;
timeZone:string|null;
warnOnLeavingUnsaved:boolean;
selfNotified:boolean;
notifications:NotificationSetting[];
}

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Query } from "@datorama/akita";
import { map } from "rxjs/operators";
import { Observable } from "rxjs";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
import { UserPreferencesModel } from "core-app/features/user-preferences/state/user-preferences.model";
import { NotificationSetting } from "core-app/features/user-preferences/state/notification-setting.model";
@Injectable()
export class UserPreferencesQuery extends Query<UserPreferencesModel> {
/** All notification settings */
notificationSettings$ = this.select('notifications');
/** Notification settings grouped by Project */
notificationsGroupedByProject$:Observable<{ [key:string]:NotificationSetting[] }> = this
.notificationSettings$
.pipe(
map(notifications => _.groupBy(notifications, setting => setting._links.project.title || 'global'))
);
projectNotifications$ = this
.notificationSettings$
.pipe(
map(settings => settings.filter(notification => notification._links.project.href !== null))
);
preferences$ = this.select();
constructor(protected store:UserPreferencesStore) {
super(store);
}
}

@ -0,0 +1,57 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { APIV3Service } from "core-app/core/apiv3/api-v3.service";
import { NotificationsService } from "core-app/shared/components/notifications/notifications.service";
import { Apiv3UserPreferencesPaths } from "core-app/core/apiv3/endpoints/users/apiv3-user-preferences-paths";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { UserPreferencesModel } from "core-app/features/user-preferences/state/user-preferences.model";
import { UserPreferencesStore } from "core-app/features/user-preferences/state/user-preferences.store";
@Injectable({ providedIn: 'root' })
export class UserPreferencesService {
constructor(
private store:UserPreferencesStore,
private http:HttpClient,
private apiV3Service:APIV3Service,
private notifications:NotificationsService,
private I18n:I18nService,
) {
}
get(user:string):void {
this.store.setLoading(true);
this.preferenceAPI(user)
.get()
.subscribe(
prefs => this.store.update(prefs),
error => this.notifications.addError(error)
)
.add(
() => this.store.setLoading(false)
);
}
update(user:string, delta:Partial<UserPreferencesModel>):void {
this.store.setLoading(true);
this
.preferenceAPI(user)
.patch(delta)
.subscribe(
prefs => {
this.store.update(prefs);
this.notifications.addSuccess(this.I18n.t('js.notice_successful_update'));
},
error => this.notifications.addError(error),
)
.add(() => this.store.setLoading(false));
}
private preferenceAPI(user:string):Apiv3UserPreferencesPaths {
return this
.apiV3Service
.users
.id(user)
.preferences;
}
}

@ -0,0 +1,51 @@
//-- 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.
//++
import { Injectable } from "@angular/core";
import { Store, StoreConfig } from '@datorama/akita';
import { UserPreferencesModel } from "core-app/features/user-preferences/state/user-preferences.model";
function createInitialState():UserPreferencesModel {
return {
autoHidePopups: true,
commentSortDescending: false,
hideMail: true,
timeZone: null,
warnOnLeavingUnsaved: true,
selfNotified: false,
notifications: []
};
}
@Injectable()
@StoreConfig({ name: 'notification-settings' })
export class UserPreferencesStore extends Store<UserPreferencesModel> {
constructor() {
super(createInitialState());
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save