diff --git a/app/contracts/notifications/create_mention_from_api_contract.rb b/app/contracts/notifications/create_mention_from_api_contract.rb new file mode 100644 index 000000000000..28f5d107ed51 --- /dev/null +++ b/app/contracts/notifications/create_mention_from_api_contract.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +module Notifications + # Validates notifications that are created on behalf of the current user + # mentioning another user in a resource, e.g. a document + # (see Notifications::CreateFromApiService). + # + # A resource type needs to opt in to this by defining `api_mention_permission` + # on its Notifications::CreateFromModelService strategy. That permission is required + # for the mentioning user while the strategy's `permission` is required for the + # mentioned user (recipient). + class CreateMentionFromApiContract < CreateContract + validate :validate_resource_mentionable + validate :validate_recipient_mentionable + + protected + + # Only mentions can be created this way. This replaces the check of the + # parent contract which only requires a reason to be present. + def validate_reason_present + errors.add(:reason, :inclusion) unless model.reason_mentioned? + end + + def validate_resource_mentionable + if strategy.nil? + errors.add(:resource, :mentions_not_supported) + elsif !resource_visible? + errors.add(:base, :error_not_found) + elsif !user.allowed_in_project?(strategy.api_mention_permission, resource_project) + errors.add(:base, :error_unauthorized) + end + end + + def validate_recipient_mentionable + return if model.recipient.nil? || strategy.nil? || !resource_visible? + + if !recipient_active? + errors.add(:recipient, :not_an_active_user) + elsif !recipient_allowed? + errors.add(:recipient, :not_allowed) + end + end + + private + + def strategy + return @strategy if defined?(@strategy) + + strategy = Notifications::CreateFromModelService.strategy_for(model.resource_type) + + @strategy = strategy.respond_to?(:api_mention_permission) ? strategy : nil + end + + def resource_visible? + return @resource_visible if defined?(@resource_visible) + + @resource_visible = model.resource.present? && model.resource.visible?(user) + end + + def recipient_active? + model.recipient.active? && !model.recipient.builtin? + end + + def recipient_allowed? + model.recipient.allowed_in_project?(strategy.permission(nil, :mentioned), resource_project) + end + + def resource_project + model.resource.project + end + end +end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 5f0dfeb66192..1cee4290505e 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -102,7 +102,8 @@ def self.email_settings belongs_to :user include Scopes::Scoped - scopes :applicable + scopes :applicable, + :enabled_for # rubocop:disable Naming/PredicateMethod def start_date_active diff --git a/app/models/notification_settings/scopes/enabled_for.rb b/app/models/notification_settings/scopes/enabled_for.rb new file mode 100644 index 000000000000..f624142c6a86 --- /dev/null +++ b/app/models/notification_settings/scopes/enabled_for.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +module NotificationSettings::Scopes + module EnabledFor + extend ActiveSupport::Concern + + class_methods do + # Returns the notification settings that prevail in the given project + # (see #applicable) and have the given reason (e.g. :mentioned) enabled. + # A project specific setting overrides the global one in both directions. + def enabled_for(reason, project) + where(reason => true) + .applicable(project) + end + end + end +end diff --git a/app/services/notifications/create_from_api_service.rb b/app/services/notifications/create_from_api_service.rb new file mode 100644 index 000000000000..a4b0ce0fe33b --- /dev/null +++ b/app/services/notifications/create_from_api_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +module Notifications + # Creates a notification requested via the API. Currently, only mentions are supported, + # i.e. notifications for a user (recipient) being mentioned by the current user + # in a resource, e.g. a document. Contrary to notifications created by + # Notifications::CreateFromModelService, these notifications are not bound to a journal. + # + # Mentioning is validated by Notifications::CreateMentionFromApiContract. If the mention is valid + # but the recipient is not to be notified, because the recipient mentioned themselves + # or has disabled notifications for being mentioned, a successful result without a + # notification is returned. + # + # If the resource's strategy supports mails for the reason, the mail is sent right away, + # just like Notifications::WorkflowJob does for mentions in journals. + class CreateFromApiService + attr_reader :user + + def initialize(user:) + @user = user + end + + # @param reason [String, Symbol] has to be "mentioned" + # @param resource_type [String] class name of the resource, e.g. "Document" + # @param resource_id [Integer, String] + # @param recipient_id [Integer, String] + def call(reason:, resource_type:, resource_id:, recipient_id:) + notification = build_notification(reason:, resource_type:, resource_id:, recipient_id:) + + contract = Notifications::CreateMentionFromApiContract.new(notification, user) + + return ServiceResult.failure(result: notification, errors: contract.errors) unless contract.valid? + return ServiceResult.success if skip_notification?(notification) + + Notifications::CreateService + .new(user:, model: notification) + .call + .tap { |result| send_mail(result.result) if result.success? } + end + + private + + def build_notification(reason:, resource_type:, resource_id:, recipient_id:) + reason = reason.to_s + strategy = Notifications::CreateFromModelService.strategy_for(resource_type) + + Notification.new( + reason: Notification.reasons.key?(reason) ? reason : nil, + # Only types having a strategy are kept. That way, arbitrary (non existing) class names, + # which would fail to load the resource, never end up on the notification. The contract + # reports the missing support for the resource type. + resource_type: strategy ? resource_type : nil, + resource_id:, + recipient_id:, + actor: user, + journal: nil, + read_ian: strategy&.supports_ian?(reason) ? false : nil, + mail_alert_sent: strategy&.supports_mail?(reason) ? false : nil, + mail_reminder_sent: nil + ) + end + + def send_mail(notification) + return unless notification.mail_alert_sent == false + + # Enqueue mail asynchronously (not waiting for delivery) + Notifications::MailService + .new(notification) + .call + end + + def skip_notification?(notification) + notification.recipient == user || + !mention_notification_enabled?(notification) + end + + def mention_notification_enabled?(notification) + NotificationSetting + .enabled_for(NotificationSetting::MENTIONED, notification.resource.project) + .exists?(user: notification.recipient) + end + end +end diff --git a/app/services/notifications/create_from_model_service.rb b/app/services/notifications/create_from_model_service.rb index b556333fb0ed..dcfbca97f4e6 100644 --- a/app/services/notifications/create_from_model_service.rb +++ b/app/services/notifications/create_from_model_service.rb @@ -57,6 +57,15 @@ class Notifications::CreateFromModelService QUOTED_LINES_PATTERN = /^ ?> .*$/ MENTION_PATTERN = Regexp.new(COMBINED_MENTION_PATTERN) + def self.strategy_for(resource_class_name) + # The resource_class_name can come from the API, so only plain class names are accepted + return unless resource_class_name.to_s.match?(/\A[A-Z]\w*\z/) + + strategy_name = :"#{resource_class_name}Strategy" + + const_get(strategy_name, false) if const_defined?(strategy_name, false) + end + def initialize(model) self.model = model end @@ -135,8 +144,11 @@ def update_notification(recipient_id, reason) end def delete_outdated_notifications(current_recipient_ids) + # Scoping by resource is necessary as for models without journals (e.g. comments) + # journal is nil which would otherwise match all notifications without a journal, + # e.g. date alerts or mentions created via the API. Notification - .where(journal:) + .where(resource:, journal:) .where.not(recipient_id: current_recipient_ids) .destroy_all end @@ -239,14 +251,19 @@ def settings_of_scheduled end def project_applicable_settings(user_scope, project, reason) - settings_for_allowed_users(user_scope, reason) - .applicable(project) + NotificationSetting + .enabled_for(reason, project) + .where(user: allowed_users(user_scope, reason)) end def settings_for_allowed_users(user_scope, reason) NotificationSetting .where(reason => true) - .where(user: user_scope.where(id: User.allowed(strategy.permission(journal, reason), project))) + .where(user: allowed_users(user_scope, reason)) + end + + def allowed_users(user_scope, reason) + user_scope.where(id: User.allowed(strategy.permission(journal, reason), project)) end # Returns the text of the model (work package description and subject, message subject and content) @@ -354,9 +371,7 @@ def receivers_hash end def strategy - @strategy ||= if self.class.const_defined?(:"#{resource.class}Strategy") - "#{self.class}::#{resource.class}Strategy".constantize - end + @strategy ||= self.class.strategy_for(resource.class) end def supported? diff --git a/config/locales/en.yml b/config/locales/en.yml index 08a11a28319f..9b09ca35adef 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -800,6 +800,12 @@ en: date: format: "%{message}" taken: "A non-working day already exists for %{value}." + notification: + attributes: + recipient: + not_an_active_user: "is not an active user." + resource: + mentions_not_supported: "does not support mentioning users." notifications: at_least_one_channel: "At least one channel for sending notifications needs to be specified." attributes: diff --git a/docs/api/apiv3/components/examples/notification_created_response.yml b/docs/api/apiv3/components/examples/notification_created_response.yml new file mode 100644 index 000000000000..6330cb35eeb4 --- /dev/null +++ b/docs/api/apiv3/components/examples/notification_created_response.yml @@ -0,0 +1,12 @@ +# Example: Notification created response +--- +value: + _type: Notification + id: 42 + _links: + resource: + href: '/api/v3/documents/5' + title: Meeting minutes + recipient: + href: '/api/v3/users/13' + title: Darth Nihilus diff --git a/docs/api/apiv3/components/examples/notification_mention_create_request.yml b/docs/api/apiv3/components/examples/notification_mention_create_request.yml new file mode 100644 index 000000000000..600a3919bbaa --- /dev/null +++ b/docs/api/apiv3/components/examples/notification_mention_create_request.yml @@ -0,0 +1,9 @@ +# Example: Notification mention create request +--- +value: + reason: mentioned + _links: + resource: + href: '/api/v3/documents/5' + recipient: + href: '/api/v3/users/13' diff --git a/docs/api/apiv3/components/schemas/notification_created_model.yml b/docs/api/apiv3/components/schemas/notification_created_model.yml new file mode 100644 index 000000000000..187847c3ffde --- /dev/null +++ b/docs/api/apiv3/components/schemas/notification_created_model.yml @@ -0,0 +1,35 @@ +# Schema: NotificationCreatedModel +--- +type: object +description: |- + Reduced representation of a notification returned to the user creating it. As the notification belongs to its + recipient, it neither links to itself nor to the read/unread actions, and nothing is embedded. +properties: + _type: + type: string + enum: + - Notification + id: + type: integer + description: Notification id + minimum: 1 + _links: + type: object + required: + - resource + - recipient + properties: + resource: + allOf: + - $ref: './link.yml' + - description: |- + The resource the recipient is mentioned in + + **Resource**: Polymorphic (currently only Document) + recipient: + allOf: + - $ref: './link.yml' + - description: |- + The user being notified + + **Resource**: User diff --git a/docs/api/apiv3/components/schemas/notification_mention_write_model.yml b/docs/api/apiv3/components/schemas/notification_mention_write_model.yml new file mode 100644 index 000000000000..b5abd462d8c9 --- /dev/null +++ b/docs/api/apiv3/components/schemas/notification_mention_write_model.yml @@ -0,0 +1,40 @@ +# Schema: NotificationMentionWriteModel +--- +type: object +required: + - reason + - _links +properties: + reason: + type: string + description: The reason for the notification. Only mentions can be created. + enum: + - mentioned + _links: + type: object + required: + - resource + - recipient + properties: + resource: + allOf: + - $ref: './link.yml' + - description: |- + The resource the recipient is mentioned in. Currently, only documents support mentioning users. + + **Resource**: Document + + # Conditions + + **Permission**: manage documents in the document's project + recipient: + allOf: + - $ref: './link.yml' + - description: |- + The user being mentioned. Needs to be an active user. + + **Resource**: User + + # Conditions + + **Permission** (of the recipient): view documents in the document's project diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 5212453005fb..3d867fe1b182 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -734,6 +734,10 @@ components: $ref: "./components/examples/mentioned_notification.yml" NotificationCollection: $ref: "./components/examples/notification_collection.yml" + NotificationCreatedResponse: + $ref: "./components/examples/notification_created_response.yml" + NotificationMentionCreateRequest: + $ref: "./components/examples/notification_mention_create_request.yml" PlaceholderUserResponse: $ref: "./components/examples/placeholder-user-response.yml" Portfolio: @@ -1032,6 +1036,10 @@ components: "$ref": "./components/schemas/non_working_day_model.yml" NotificationCollectionModel: "$ref": "./components/schemas/notification_collection_model.yml" + NotificationCreatedModel: + "$ref": "./components/schemas/notification_created_model.yml" + NotificationMentionWriteModel: + "$ref": "./components/schemas/notification_mention_write_model.yml" NotificationModel: "$ref": "./components/schemas/notification_model.yml" OAuthApplicationReadModel: diff --git a/docs/api/apiv3/paths/notifications.yml b/docs/api/apiv3/paths/notifications.yml index 24e29d70f4b4..05002c138c5e 100644 --- a/docs/api/apiv3/paths/notifications.yml +++ b/docs/api/apiv3/paths/notifications.yml @@ -107,3 +107,87 @@ get: errorIdentifier: urn:openproject-org:api:v3:errors:InvalidQuery message: Filters Invalid filter does not exist. description: Returned if the client sends invalid request parameters e.g. filters +post: + summary: Create mention notification + operationId: create_notification + tags: + - Notifications + description: |- + Notifies the recipient that the current user mentioned them in the referenced resource. + Currently, only documents support mentioning users this way. + + If the request is valid but the recipient is not to be notified, no notification is created and the response + has no content. This is the case if the recipient is the current user or if the recipient has disabled + notifications for being mentioned, either globally or for the resource's project. + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/notification_mention_write_model.yml' + examples: + 'Mention a user in a document': + $ref: '../components/examples/notification_mention_create_request.yml' + responses: + '201': + description: |- + Created. As the notification belongs to the recipient, only a reduced representation is returned. + content: + application/hal+json: + schema: + $ref: '../components/schemas/notification_created_model.yml' + examples: + 'Created notification': + $ref: '../components/examples/notification_created_response.yml' + '204': + description: |- + Returned if the request is valid but no notification is created, because the recipient is the current user + or has disabled notifications for being mentioned. + '400': + $ref: '../components/responses/invalid_request_body.yml' + '403': + description: |- + Returned if the client is not logged in and login is required or if the client does not have sufficient + permissions. + + **Required permission:** manage documents in the document's project + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + '404': + description: Returned if the resource does not exist or the client does not have sufficient permissions to see it. + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + '415': + $ref: '../components/responses/unsupported_media_type.yml' + '422': + description: |- + Returned if: + + - a constraint for a property was violated (`PropertyConstraintViolation`), e.g. + - the `reason` is not `mentioned` + - the `resource` does not support mentioning users via the API (e.g. a work package) + - the `recipient` does not exist, is not an active user or is not allowed to view the resource + - the client provides a link to an invalid resource (`ResourceTypeMismatch`) + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _embedded: + details: + attribute: resource + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Resource does not support mentioning users. diff --git a/docs/api/apiv3/tags/notifications.yml b/docs/api/apiv3/tags/notifications.yml index 8b6a3531249e..15fb39cce7a1 100644 --- a/docs/api/apiv3/tags/notifications.yml +++ b/docs/api/apiv3/tags/notifications.yml @@ -4,7 +4,7 @@ description: |- Notifications are triggered by actions carried out in the system by users, e.g. editing a work package, but can also be send out because of time passing e.g. when a user is notified of a work package that is overdue. - This endpoint only returns in-app notifications. + Listing notifications only returns in-app notifications, not notifications that are only send via email. ## Actions diff --git a/lib/api/v3/notifications/notification_created_representer.rb b/lib/api/v3/notifications/notification_created_representer.rb new file mode 100644 index 000000000000..a4d7c69e1a90 --- /dev/null +++ b/lib/api/v3/notifications/notification_created_representer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module Notifications + # Reduced representation of a notification returned to the user creating it + # (POST /api/v3/notifications), e.g. when mentioning someone in a document. + # + # The notification belongs to its recipient, not to the creating user. The full + # NotificationRepresenter would therefore advertise links the creator cannot follow: + # the self link (GET /api/v3/notifications/:id is scoped to the recipient and 404s + # for anyone else) as well as the readIAN/unreadIAN actions. This representer only + # renders the id and the links to the resource and the recipient, both of which the + # creator is allowed to see. Nothing is embedded. + class NotificationCreatedRepresenter < ::API::Decorators::Single + include API::Decorators::LinkedResource + extend API::Decorators::PolymorphicResource + + property :id + + polymorphic_resource :resource, + link_title_attribute: :title + + associated_resource :recipient, + representer: ::API::V3::Users::UserRepresenter, + v3_path: :user + + def _type + "Notification" + end + end + end + end +end diff --git a/lib/api/v3/notifications/notifications_api.rb b/lib/api/v3/notifications/notifications_api.rb index c953ae9592a0..bf97ccfa8844 100644 --- a/lib/api/v3/notifications/notifications_api.rb +++ b/lib/api/v3/notifications/notifications_api.rb @@ -49,6 +49,40 @@ def notification_scope .order(id: :desc) end + def mention_request_body + request_body.is_a?(Hash) ? request_body : {} + end + + def mention_link_href(name) + links = mention_request_body["_links"] + link = links.is_a?(Hash) ? links[name] : nil + link["href"] if link.is_a?(Hash) + end + + # Returns the class name (e.g. "Document") and the id of the resource + # referenced by the href (e.g. "/api/v3/documents/5"). + def mention_resource_type_and_id + href = mention_link_href("resource") + + return [nil, nil] if href.nil? + + id = ::API::Utilities::ResourceLinkParser.parse_id(href, property: "resource", expected_version: "3") + namespace = ::API::Utilities::ResourceLinkParser.parse(href)[:namespace] + + [namespace.singularize.camelize, id] + end + + def mention_recipient_id + href = mention_link_href("recipient") + + return if href.nil? + + ::API::Utilities::ResourceLinkParser.parse_id(href, + property: "recipient", + expected_version: "3", + expected_namespace: "users") + end + def bulk_update_status(attributes) if notification_query.valid? notification_query.results.update_all({ updated_at: Time.zone.now }.merge(attributes)) @@ -65,6 +99,33 @@ def bulk_update_status(attributes) .new(model: Notification, scope: -> { Notification.where.not(read_ian: nil) }) .mount + # Creates a notification for the recipient being mentioned by the current user + # in the referenced resource. Only resource types whose + # Notifications::CreateFromModelService strategy opts in via `api_mention_permission` + # are supported. As the notification belongs to the recipient, only a reduced + # representation (id, resource and recipient links) is returned with 201. + # If the recipient is not to be notified (e.g. because of their notification settings), + # no notification is created and 204 is returned. + post do + resource_type, resource_id = mention_resource_type_and_id + + call = ::Notifications::CreateFromApiService + .new(user: current_user) + .call(reason: mention_request_body["reason"], + resource_type:, + resource_id:, + recipient_id: mention_recipient_id) + + fail ::API::Errors::ErrorBase.create_and_merge_errors(call.errors) if call.failure? + + if call.result + status 201 + NotificationCreatedRepresenter.create(call.result, current_user:, embed_links: false) + else + status 204 + end + end + post :read_ian do bulk_update_status(read_ian: true) end diff --git a/modules/documents/app/mailers/documents_mailer.rb b/modules/documents/app/mailers/documents_mailer.rb index c090e29025ee..8c2c3b8b3349 100644 --- a/modules/documents/app/mailers/documents_mailer.rb +++ b/modules/documents/app/mailers/documents_mailer.rb @@ -28,6 +28,27 @@ #++ class DocumentsMailer < UserMailer + def mentioned(recipient, notification) + @user = recipient + @document = notification.resource + @author = notification.actor + + User.execute_as @author do + open_project_headers "Project" => @document.project.identifier, + "Type" => "Document" + + message_id notification, recipient + references @document + + send_localized_mail(recipient) do + I18n.t(:"documents.mail.mentioned.subject", + project_name: @document.project.name, + user_name: @author.name, + title: @document.title) + end + end + end + def document_added(user, document) @document = document diff --git a/modules/documents/app/models/document.rb b/modules/documents/app/models/document.rb index b2117ae4a547..cf0118732ee2 100644 --- a/modules/documents/app/models/document.rb +++ b/modules/documents/app/models/document.rb @@ -35,6 +35,9 @@ class Document < ApplicationRecord belongs_to :project belongs_to :type, class_name: "DocumentType", optional: true, counter_cache: :documents_count + # Notifications created via the API (e.g. mentions) have no journal and would + # thus not be removed together with the document's journals. + has_many :notifications, as: :resource, dependent: :delete_all acts_as_attachable delete_permission: :manage_documents, add_permission: :manage_documents diff --git a/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb b/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb index ece360f5964f..ae719790e5de 100644 --- a/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb +++ b/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb @@ -32,10 +32,22 @@ def self.reasons %i(subscribed) end + # The permission the recipient needs in order to + # receive the notification. def self.permission(*) :view_documents end + # Opts documents in to having mention notifications created via + # POST /api/v3/notifications. The returned permission is the one + # the mentioning user needs in the document's project. + def self.api_mention_permission + :manage_documents + end + + # The notification center does not support documents yet. Hence, notifications + # for documents (both added documents and mentions created via the API) are only + # sent out by mail. def self.supports_ian?(_reason) false end diff --git a/modules/documents/app/services/notifications/mail_service/document_strategy.rb b/modules/documents/app/services/notifications/mail_service/document_strategy.rb index d5c9197c730d..2e6039c2fdc5 100644 --- a/modules/documents/app/services/notifications/mail_service/document_strategy.rb +++ b/modules/documents/app/services/notifications/mail_service/document_strategy.rb @@ -30,7 +30,25 @@ module Notifications::MailService::DocumentStrategy class << self def send_mail(notification) - return unless notification.journal.initial? + if notification.reason_mentioned? + send_mentioned_mail(notification) + else + send_document_added_mail(notification) + end + end + + private + + def send_mentioned_mail(notification) + return unless notification.recipient.pref.immediate_reminders[:mentioned] + + DocumentsMailer + .mentioned(notification.recipient, notification) + .deliver_later + end + + def send_document_added_mail(notification) + return unless notification.journal&.initial? DocumentsMailer .document_added( diff --git a/modules/documents/app/views/documents_mailer/mentioned.html.erb b/modules/documents/app/views/documents_mailer/mentioned.html.erb new file mode 100644 index 000000000000..c3906f326af0 --- /dev/null +++ b/modules/documents/app/views/documents_mailer/mentioned.html.erb @@ -0,0 +1,76 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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 COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render layout: "mailer/spacer_table" do %> + <%= render partial: "mailer/mailer_header", + locals: { + summary: I18n.t(:"documents.mail.mentioned.mentioned_by", user: @author), + button_href: document_url(@document), + button_text: I18n.t(:"documents.mail.mentioned.open_document"), + user: @user + } %> + + + <%= render layout: "mailer/border_table" do %> + + <%= placeholder_cell("12px", vertical: true) %> + + <%= @document.project.name %> + + <%= placeholder_cell("12px", vertical: true) %> + + + <%= placeholder_cell("12px", vertical: true) %> + <%= placeholder_cell("16px", vertical: false) %> + <%= placeholder_cell("12px", vertical: true) %> + + + <%= placeholder_cell("12px", vertical: true) %> + + <%= @document.title %> + + <%= placeholder_cell("12px", vertical: true) %> + + <% end %> + + + > + + <%= placeholder_cell("24px", vertical: false) %> + +
+ + <%= render partial: "mailer/notification_settings_table", + locals: { + button_url: my_notifications_url(tab: "reminders"), + button_text: I18n.t(:"mail.notification.settings") + } %> +<% end %> diff --git a/modules/documents/app/views/documents_mailer/mentioned.text.erb b/modules/documents/app/views/documents_mailer/mentioned.text.erb new file mode 100644 index 000000000000..bc2c60436c9f --- /dev/null +++ b/modules/documents/app/views/documents_mailer/mentioned.text.erb @@ -0,0 +1,39 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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 COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= user_salutation(@user) %> +<%= I18n.t(:"documents.mail.mentioned.mentioned_by", user: @author) %> +<%= "-" * 100 %> + +<%= @document.project.name %> +<%= @document.title %> + +<%= I18n.t(:"documents.mail.mentioned.open_document") %>: <%= document_url(@document) %> + +<%= "-" * 100 %> diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index 1ffd055ed18b..1bc35a9d01dc 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -141,6 +141,12 @@ en: label_categories: "Categories" last_updated_at_html: "Last saved %{time}." + mail: + mentioned: + mentioned_by: "%{user} mentioned you in a document" + open_document: "Open document" + subject: "[%{project_name}] %{user_name} mentioned you in %{title}" + menu: all: "All documents" collaboration_settings: "Real-time collaboration" diff --git a/modules/documents/spec/mailers/documents_mailer_spec.rb b/modules/documents/spec/mailers/documents_mailer_spec.rb index 20b5b7a4e3b1..7fb5c27f3bc3 100644 --- a/modules/documents/spec/mailers/documents_mailer_spec.rb +++ b/modules/documents/spec/mailers/documents_mailer_spec.rb @@ -61,6 +61,70 @@ end end + describe "#mentioned", with_settings: { host_name: "my.openproject.com" } do + let(:actor) { create(:user, firstname: "Mentioning", lastname: "Actor") } + let(:notification) do + build_stubbed(:notification, + reason: :mentioned, + resource: document, + journal: nil, + recipient: user, + actor:) + end + let(:mail) { described_class.mentioned(user, notification) } + let(:document_url) { "http://my.openproject.com/documents/#{document.id}" } + + it "renders the subject" do + expect(mail.subject).to eq "[TestProject] Mentioning Actor mentioned you in Test Title" + end + + it "is sent to the recipient" do + expect(mail.to).to eq [user.mail] + end + + it "sets the OpenProject headers" do + expect(mail["X-OpenProject-Project"].value).to eq project.identifier + expect(mail["X-OpenProject-Type"].value).to eq "Document" + end + + it "uses the notification for the Message-ID and references the document" do + expect(mail["Message-ID"].value) + .to match(/\A]+>\z/) + expect(mail["References"].value).to match(/\A]+>\z/) + end + + context "with two mentions of the same recipient in the same document at the same time" do + let(:other_notification) do + build_stubbed(:notification, + reason: :mentioned, + resource: document, + journal: nil, + recipient: user, + actor:) + end + let(:other_mail) { described_class.mentioned(user, other_notification) } + + it "uses distinct Message-IDs but the same References", :freeze_time do + expect(mail["Message-ID"].value).not_to eq other_mail["Message-ID"].value + expect(mail["References"].value).to eq other_mail["References"].value + end + end + + it "renders the actor, the project, the document title and link in every format" do + expect(mail.parts.map { |part| part.body.to_s }) + .to all include("Mentioning Actor mentioned you in a document") + .and include("TestProject") + .and include("Test Title") + .and include(document_url) + end + + it "links the button to the document" do + expect(mail.html_part.body.to_s) + .to include(%(href="#{document_url}")) + .and include("Open document") + end + end + describe "document description referencing a work package", with_settings: { host_name: "my.openproject.com" } do shared_let(:persisted_project) { create(:project, identifier: "docsproj") } diff --git a/modules/documents/spec/models/document_spec.rb b/modules/documents/spec/models/document_spec.rb index 348c70a03eae..84680cc96fdb 100644 --- a/modules/documents/spec/models/document_spec.rb +++ b/modules/documents/spec/models/document_spec.rb @@ -52,6 +52,20 @@ describe "Associations" do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:type).class_name("DocumentType").optional } + it { is_expected.to have_many(:notifications).dependent(:delete_all) } + end + + describe "destroying" do + let(:document) { create(:document, project:) } + let!(:notification) { create(:notification, resource: document, journal: nil, reason: :mentioned) } + let!(:other_notification) { create(:notification, resource: create(:document, project:), journal: nil) } + + it "deletes the notifications of the document" do + document.destroy + + expect(Notification).not_to exist(id: notification.id) + expect(Notification).to exist(id: other_notification.id) + end end describe "title normalization" do diff --git a/modules/documents/spec/services/notifications/create_from_model_service/document_strategy_spec.rb b/modules/documents/spec/services/notifications/create_from_model_service/document_strategy_spec.rb new file mode 100644 index 000000000000..6f7bfc3780e9 --- /dev/null +++ b/modules/documents/spec/services/notifications/create_from_model_service/document_strategy_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Notifications::CreateFromModelService::DocumentStrategy do + it "opts in to mentions via the API requiring the manage_documents permission" do + expect(described_class.api_mention_permission).to eq(:manage_documents) + end + + describe ".supports_ian?" do + it { expect(described_class.supports_ian?(:mentioned)).to be(false) } + it { expect(described_class.supports_ian?(:subscribed)).to be(false) } + end + + describe ".supports_mail?" do + it { expect(described_class.supports_mail?(:mentioned)).to be(true) } + it { expect(described_class.supports_mail?(:subscribed)).to be(true) } + end + + describe ".supports_mail_digest?" do + it { expect(described_class.supports_mail_digest?(:mentioned)).to be(false) } + it { expect(described_class.supports_mail_digest?(:subscribed)).to be(false) } + end + + it "keeps subscribed as the only journal based reason" do + expect(described_class.reasons).to eq(%i[subscribed]) + end +end diff --git a/modules/documents/spec/services/notifications/mail_service_spec.rb b/modules/documents/spec/services/notifications/mail_service_spec.rb index 7007e9a70d91..604dbc604eac 100644 --- a/modules/documents/spec/services/notifications/mail_service_spec.rb +++ b/modules/documents/spec/services/notifications/mail_service_spec.rb @@ -33,12 +33,34 @@ subject(:call) { instance.call } let(:recipient) do - build_stubbed(:user) + build_stubbed(:user, + preferences: { + immediate_reminders: { + mentioned: immediate_reminders_mentioned + } + }) end + let(:immediate_reminders_mentioned) { true } let(:actor) do build_stubbed(:user) end let(:instance) { described_class.new(notification) } + let(:read_ian) { nil } + let(:mail) do + mail = instance_double(ActionMailer::MessageDelivery) + + allow(DocumentsMailer) + .to receive_messages(document_added: mail, mentioned: mail) + + allow(mail) + .to receive(:deliver_later) + + mail + end + + before do + mail + end context "with a document journal notification" do let(:journal) do @@ -49,35 +71,18 @@ .and_return(initial_journal) end end - let(:read_ian) { false } let(:notification) do build_stubbed(:notification, journal:, + reason: :subscribed, resource: journal.journable, recipient:, actor:, read_ian:) end - let(:notification_setting) { %w(document_added) } - let(:mail) do - mail = instance_double(ActionMailer::MessageDelivery) - - allow(DocumentsMailer) - .to receive(:document_added) - .and_return(mail) - - allow(mail) - .to receive(:deliver_later) - - mail - end let(:initial_journal) { true } - before do - mail - end - - it "sends a mail" do + it "sends a document added mail" do call expect(DocumentsMailer) @@ -87,6 +92,9 @@ expect(mail) .to have_received(:deliver_later) + + expect(DocumentsMailer) + .not_to have_received(:mentioned) end context "with the notification read in app already" do @@ -111,4 +119,44 @@ end end end + + context "with a mention notification without a journal" do + let(:document) { build_stubbed(:document) } + let(:notification) do + build_stubbed(:notification, + journal: nil, + reason: :mentioned, + resource: document, + recipient:, + actor:, + read_ian:) + end + + it "sends a mentioned mail" do + call + + expect(DocumentsMailer) + .to have_received(:mentioned) + .with(recipient, notification) + + expect(mail) + .to have_received(:deliver_later) + + expect(DocumentsMailer) + .not_to have_received(:document_added) + end + + context "with the recipient having deactivated immediate mails for mentions" do + let(:immediate_reminders_mentioned) { false } + + it "sends no mail" do + call + + expect(DocumentsMailer) + .not_to have_received(:mentioned) + expect(DocumentsMailer) + .not_to have_received(:document_added) + end + end + end end diff --git a/spec/contracts/notifications/create_mention_from_api_contract_spec.rb b/spec/contracts/notifications/create_mention_from_api_contract_spec.rb new file mode 100644 index 000000000000..a09ca7c3432b --- /dev/null +++ b/spec/contracts/notifications/create_mention_from_api_contract_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Notifications::CreateMentionFromApiContract do + include_context "ModelContract shared context" + + let(:project) { build_stubbed(:project) } + let(:document) { build_stubbed(:document, project:) } + let(:current_user) { build_stubbed(:user) } + let(:recipient) { build_stubbed(:user) } + let(:resource) { document } + let(:reason) { :mentioned } + let(:current_user_permissions) { %i[view_documents manage_documents] } + let(:recipient_permissions) { %i[view_documents] } + + let(:notification) do + Notification.new(recipient:, + reason:, + resource:, + actor: current_user, + read_ian: false) + end + + let(:contract) { described_class.new(notification, current_user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_in_project(*current_user_permissions, project:) + end + if recipient + mock_permissions_for(recipient) do |mock| + mock.allow_in_project(*recipient_permissions, project:) + end + end + end + + describe "#validation" do + it_behaves_like "contract is valid" + + context "with a reason other than mentioned", :check_errors_i18n do + let(:reason) { :assigned } + + it_behaves_like "contract is invalid", reason: :inclusion + end + + context "without a reason" do + let(:reason) { nil } + + it_behaves_like "contract is invalid", reason: :inclusion + end + + context "with a resource whose strategy does not opt in to API mentions", :check_errors_i18n do + let(:resource) { build_stubbed(:work_package, project:) } + + it_behaves_like "contract is invalid", resource: :mentions_not_supported + end + + context "with a resource without a strategy" do + let(:resource) { build_stubbed(:project) } + + it_behaves_like "contract is invalid", resource: :mentions_not_supported + end + + context "without a resource" do + let(:resource) { nil } + + it_behaves_like "contract is invalid", resource: :mentions_not_supported + end + + context "when the document is not visible to the current user" do + let(:current_user_permissions) { [] } + + it_behaves_like "contract is invalid", base: :error_not_found + end + + context "when the current user may view but not edit the document" do + let(:current_user_permissions) { %i[view_documents] } + + it_behaves_like "contract user is unauthorized" + end + + context "without a recipient" do + let(:recipient) { nil } + + it_behaves_like "contract is invalid", recipient: :blank + end + + context "with a locked recipient", :check_errors_i18n do + let(:recipient) { build_stubbed(:user, status: :locked) } + + it_behaves_like "contract is invalid", recipient: :not_an_active_user + end + + context "with a builtin recipient" do + let(:recipient) { build_stubbed(:anonymous) } + + it_behaves_like "contract is invalid", recipient: :not_an_active_user + end + + context "with a recipient not allowed to view the document" do + let(:recipient_permissions) { [] } + + it_behaves_like "contract is invalid", recipient: :not_allowed + end + end +end diff --git a/spec/lib/api/v3/notifications/notification_created_representer_rendering_spec.rb b/spec/lib/api/v3/notifications/notification_created_representer_rendering_spec.rb new file mode 100644 index 000000000000..755967f1e6dc --- /dev/null +++ b/spec/lib/api/v3/notifications/notification_created_representer_rendering_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe API::V3::Notifications::NotificationCreatedRepresenter, "rendering" do + include API::V3::Utilities::PathHelper + + subject(:generated) { representer.to_json } + + let(:project) { build_stubbed(:project) } + let(:document) { build_stubbed(:document, project:, title: "Meeting minutes") } + let(:recipient) { build_stubbed(:user, firstname: "Jane", lastname: "Doe") } + let(:actor) { build_stubbed(:user) } + let(:notification) do + build_stubbed(:notification, + recipient:, + resource: document, + actor:, + journal: nil, + reason: :mentioned, + read_ian: false) + end + let(:representer) do + described_class.create notification, + current_user: actor, + embed_links: + end + + let(:embed_links) { false } + + it "renders only the type, the id and the resource and recipient links" do + expect(JSON.parse(generated)) + .to eq("_type" => "Notification", + "id" => notification.id, + "_links" => { + "resource" => { "href" => api_v3_paths.document(document.id), "title" => "Meeting minutes" }, + "recipient" => { "href" => api_v3_paths.user(recipient.id), "title" => recipient.name } + }) + end + + describe "properties" do + it_behaves_like "property", :_type do + let(:value) { "Notification" } + end + + it_behaves_like "property", :id do + let(:value) { notification.id } + end + + it "has no further properties of the full notification representation" do + expect(generated).not_to have_json_path("readIAN") + expect(generated).not_to have_json_path("reason") + expect(generated).not_to have_json_path("createdAt") + expect(generated).not_to have_json_path("updatedAt") + end + end + + describe "_links" do + describe "resource" do + it_behaves_like "has a titled link" do + let(:link) { "resource" } + let(:href) { api_v3_paths.document(document.id) } + let(:title) { document.title } + end + end + + describe "recipient" do + it_behaves_like "has a titled link" do + let(:link) { "recipient" } + let(:href) { api_v3_paths.user(recipient.id) } + let(:title) { recipient.name } + end + end + + %w[self readIAN unreadIAN actor activity project].each do |name| + describe name do + it_behaves_like "has no link" do + let(:link) { name } + end + end + end + end + + describe "_embedded" do + it "embeds nothing" do + expect(generated).not_to have_json_path("_embedded") + end + end +end diff --git a/spec/mailers/previews/documents_mailer_preview.rb b/spec/mailers/previews/documents_mailer_preview.rb new file mode 100644 index 000000000000..c7aabe3c5dc1 --- /dev/null +++ b/spec/mailers/previews/documents_mailer_preview.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +class DocumentsMailerPreview < ActionMailer::Preview + # Preview emails at http://localhost:3000/rails/mailers/documents_mailer + + def mentioned + language = params["locale"] || I18n.default_locale + actor = FactoryBot.build_stubbed(:user, lastname: "Actor") + recipient = FactoryBot.build_stubbed(:user, language:) + project = FactoryBot.build_stubbed(:project) + document = FactoryBot.build_stubbed(:document, title: "Meeting minutes", project:) + notification = FactoryBot.build_stubbed(:notification, + reason: :mentioned, + resource: document, + journal: nil, + actor:, + recipient:) + + DocumentsMailer.mentioned(recipient, notification) + end + + def document_added + language = params["locale"] || I18n.default_locale + user = FactoryBot.build_stubbed(:user, language:) + project = FactoryBot.build_stubbed(:project) + document = FactoryBot.build_stubbed(:document, title: "Meeting minutes", project:) + + DocumentsMailer.document_added(user, document) + end +end diff --git a/spec/mailers/previews_spec.rb b/spec/mailers/previews_spec.rb index 280611d88582..60bfd5475043 100644 --- a/spec/mailers/previews_spec.rb +++ b/spec/mailers/previews_spec.rb @@ -36,11 +36,13 @@ module PendingMailerPreviews MAILERS = [ AnnouncementMailer, - DocumentsMailer, MemberMailer ].freeze MAILER_ACTIONS = { + DocumentsMailer => %i[ + attachments_added + ], MeetingMailer => %i[ ended_series icalendar_notification diff --git a/spec/requests/api/v3/notifications/notifications_api_create_resource_spec.rb b/spec/requests/api/v3/notifications/notifications_api_create_resource_spec.rb new file mode 100644 index 000000000000..91e7254f2e03 --- /dev/null +++ b/spec/requests/api/v3/notifications/notifications_api_create_resource_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "rack/test" + +RSpec.describe API::V3::Notifications::NotificationsAPI, + "create", + content_type: :json do + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, enabled_module_names: %w[documents work_package_tracking]) } + shared_let(:document) { create(:document, project:) } + shared_let(:caller_user) do + create(:user, + member_with_permissions: { project => %i[view_documents manage_documents] }) + end + shared_let(:recipient) do + create(:user, + member_with_permissions: { project => %i[view_documents] }) + end + + let(:current_user) { caller_user } + let(:reason) { "mentioned" } + let(:resource_href) { api_v3_paths.document(document.id) } + let(:recipient_href) { api_v3_paths.user(recipient.id) } + let(:body) do + { + reason:, + _links: { + resource: { href: resource_href }, + recipient: { href: recipient_href } + } + } + end + let(:parsed_response) { JSON.parse(last_response.body) } + + let(:send_request) do + post api_v3_paths.notifications, body.to_json + end + + before do + login_as current_user + end + + shared_examples_for "no notification created" do + it "responds with 204 and creates no notification" do + expect { send_request }.not_to change(Notification, :count) + + expect(last_response).to have_http_status(:no_content) + expect(last_response.body).to be_empty + end + end + + shared_examples_for "unprocessable" do |attribute| + it "responds with 422 and creates no notification" do + expect { send_request }.not_to change(Notification, :count) + + expect(last_response).to have_http_status(:unprocessable_entity) + expect(parsed_response["errorIdentifier"]) + .to eq("urn:openproject-org:api:v3:errors:PropertyConstraintViolation") + expect(parsed_response.dig("_embedded", "details", "attribute")).to eq(attribute) + end + end + + context "when mentioning a user in a document" do + it "creates a mention notification and responds with a reduced representation" do + expect { send_request }.to change(Notification, :count).by(1) + + expect(last_response).to have_http_status(:created) + + notification = Notification.last + expect(parsed_response) + .to eq("_type" => "Notification", + "id" => notification.id, + "_links" => { + "resource" => { "href" => api_v3_paths.document(document.id), "title" => document.title }, + "recipient" => { "href" => api_v3_paths.user(recipient.id), "title" => recipient.name } + }) + expect(parsed_response).not_to have_key("_embedded") + expect(parsed_response["_links"]).not_to include("self", "readIAN", "unreadIAN") + + expect(notification).to have_attributes(recipient:, + resource: document, + actor: caller_user, + reason: "mentioned", + journal: nil, + read_ian: nil, + mail_alert_sent: true, + mail_reminder_sent: nil) + end + + it "sends a mention mail to the recipient" do + perform_enqueued_jobs { send_request } + + expect(ActionMailer::Base.deliveries.size).to eq(1) + + mail = ActionMailer::Base.deliveries.first + expect(mail.to).to eq([recipient.mail]) + expect(mail.subject).to eq("[#{project.name}] #{caller_user.name} mentioned you in #{document.title}") + expect(mail.text_part.body.to_s).to include("/documents/#{document.id}") + end + end + + context "when the recipient has disabled immediate mails for mentions" do + let(:recipient_without_mention_mails) do + create(:user, + member_with_permissions: { project => %i[view_documents] }, + preferences: { immediate_reminders: { mentioned: false } }) + end + let(:recipient_href) { api_v3_paths.user(recipient_without_mention_mails.id) } + + it "creates the notification but sends no mail" do + expect { perform_enqueued_jobs { send_request } }.to change(Notification, :count).by(1) + + expect(last_response).to have_http_status(:created) + expect(Notification.last.mail_alert_sent).to be_nil + expect(ActionMailer::Base.deliveries).to be_empty + end + end + + context "when the recipient has disabled mention notifications globally" do + before do + recipient.notification_settings.update_all(mentioned: false) + end + + it_behaves_like "no notification created" + end + + context "when the recipient has disabled mention notifications for the project" do + before do + create(:notification_setting, user: recipient, project:, mentioned: false) + end + + it_behaves_like "no notification created" + end + + context "when the recipient has disabled mention notifications globally but enabled them for the project" do + before do + recipient.notification_settings.update_all(mentioned: false) + create(:notification_setting, user: recipient, project:, mentioned: true) + end + + it "creates a notification" do + expect { send_request }.to change(Notification, :count).by(1) + + expect(last_response).to have_http_status(:created) + end + end + + context "when mentioning oneself" do + let(:recipient_href) { api_v3_paths.user(caller_user.id) } + + it_behaves_like "no notification created" + end + + context "with a reason other than mentioned" do + let(:reason) { "assigned" } + + it_behaves_like "unprocessable", "reason" + end + + context "with an unknown reason" do + let(:reason) { "foo" } + + it_behaves_like "unprocessable", "reason" + end + + context "with a resource type not supporting mentions via the API (work package)" do + let(:work_package) { create(:work_package, project:) } + let(:resource_href) { api_v3_paths.work_package(work_package.id) } + + it_behaves_like "unprocessable", "resource" + end + + context "with a resource href not pointing to a known resource type" do + let(:resource_href) { api_v3_paths.user(recipient.id) } + + it_behaves_like "unprocessable", "resource" + end + + context "with a malformed resource href" do + let(:resource_href) { "/foo/bar" } + + it "responds with 422" do + expect { send_request }.not_to change(Notification, :count) + + expect(last_response).to have_http_status(:unprocessable_entity) + expect(parsed_response["errorIdentifier"]).to eq("urn:openproject-org:api:v3:errors:ResourceTypeMismatch") + end + end + + context "with a workspace nested resource href" do + let(:resource_href) { "/api/v3/workspaces/#{project.id}/documents/#{document.id}" } + + it_behaves_like "unprocessable", "resource" + end + + context "with malformed links" do + let(:body) { { reason:, _links: { resource: "foo", recipient: [] } } } + + it "responds with 422" do + send_request + + expect(last_response).to have_http_status(:unprocessable_entity) + end + end + + context "with a recipient that does not exist" do + let(:recipient_href) { api_v3_paths.user(0) } + + it_behaves_like "unprocessable", "recipient" + end + + context "with a recipient that is a group" do + let(:group) { create(:group, members: [recipient]) } + let(:recipient_href) { api_v3_paths.user(group.id) } + + it_behaves_like "unprocessable", "recipient" + end + + context "with a locked recipient" do + before do + recipient.locked! + end + + it_behaves_like "unprocessable", "recipient" + end + + context "with a recipient that is not allowed to view documents" do + let(:recipient_href) { api_v3_paths.user(create(:user, member_with_permissions: { project => [] }).id) } + + it_behaves_like "unprocessable", "recipient" + end + + context "when the document is not visible to the current user" do + let(:current_user) { create(:user) } + + before { send_request } + + it_behaves_like "not found" + end + + context "when the document does not exist" do + let(:resource_href) { api_v3_paths.document(0) } + + before { send_request } + + it_behaves_like "not found" + end + + context "when the current user may view but not edit the document" do + let(:current_user) { recipient } + let(:recipient_href) { api_v3_paths.user(caller_user.id) } + + before { send_request } + + it_behaves_like "unauthorized access" + end + + context "as an anonymous user" do + let(:current_user) { User.anonymous } + + before { send_request } + + it_behaves_like "forbidden response based on login_required" + end +end diff --git a/spec/services/notifications/create_from_api_service_spec.rb b/spec/services/notifications/create_from_api_service_spec.rb new file mode 100644 index 000000000000..f43e92ceec2c --- /dev/null +++ b/spec/services/notifications/create_from_api_service_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Notifications::CreateFromApiService do + shared_let(:project) { create(:project, enabled_module_names: %w[documents]) } + shared_let(:document) { create(:document, project:) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_documents manage_documents] }) + end + shared_let(:recipient) do + create(:user, member_with_permissions: { project => %i[view_documents] }) + end + + let(:instance) { described_class.new(user:) } + let(:reason) { "mentioned" } + let(:resource_type) { "Document" } + let(:recipient_id) { recipient.id } + + subject(:service_call) do + instance.call(reason:, resource_type:, resource_id: document.id, recipient_id:) + end + + it "creates a mention notification without journal, only to be sent out by mail" do + expect(service_call).to be_success + expect(service_call.result) + .to be_persisted + .and have_attributes(recipient:, + resource: document, + actor: user, + reason: "mentioned", + journal: nil, + read_ian: nil, + mail_reminder_sent: nil) + end + + it "sends the mention mail immediately and marks the notification accordingly" do + expect { service_call } + .to have_enqueued_mail(DocumentsMailer, :mentioned) + .with(recipient, instance_of(Notification)) + + expect(service_call.result.reload.mail_alert_sent).to be(true) + end + + context "when the recipient disabled immediate mails for mentions" do + let(:recipient_without_mention_mails) do + create(:user, + member_with_permissions: { project => %i[view_documents] }, + preferences: { immediate_reminders: { mentioned: false } }) + end + let(:recipient_id) { recipient_without_mention_mails.id } + + it "creates the notification without sending a mail" do + expect { service_call }.not_to have_enqueued_mail + + expect(service_call.result) + .to be_persisted + .and have_attributes(recipient: recipient_without_mention_mails, read_ian: nil) + expect(service_call.result.reload.mail_alert_sent).to be_nil + end + end + + context "when the recipient is the user" do + let(:recipient_id) { user.id } + + it "succeeds without creating a notification" do + expect { service_call }.not_to change(Notification, :count) + expect(service_call).to be_success + expect(service_call.result).to be_nil + end + end + + context "when the recipient disabled mention notifications for the project" do + before do + create(:notification_setting, user: recipient, project:, mentioned: false) + end + + it "succeeds without creating a notification" do + expect { service_call }.not_to change(Notification, :count) + expect(service_call).to be_success + expect(service_call.result).to be_nil + end + end + + context "when the contract is invalid" do + let(:reason) { "assigned" } + + it "fails without creating a notification" do + expect { service_call }.not_to change(Notification, :count) + expect(service_call).to be_failure + expect(service_call.errors.symbols_for(:reason)).to eq([:inclusion]) + end + end + + context "with a resource type that is no class" do + let(:resource_type) { "Foo::Bar" } + + it "fails without loading the resource" do + expect(service_call).to be_failure + expect(service_call.errors.symbols_for(:resource)).to eq([:mentions_not_supported]) + end + end +end diff --git a/spec/services/notifications/create_from_model_service_comment_spec.rb b/spec/services/notifications/create_from_model_service_comment_spec.rb index 3045ec2ec4a6..6fb32ef04699 100644 --- a/spec/services/notifications/create_from_model_service_comment_spec.rb +++ b/spec/services/notifications/create_from_model_service_comment_spec.rb @@ -159,6 +159,22 @@ it_behaves_like "creates no notification" end + + context "with notifications without a journal existing for other resources" do + let!(:other_notification) do + create(:notification, + recipient: other_user, + reason: :date_alert_start_date, + journal: nil, + resource: create(:work_package, project:)) + end + + it "keeps those notifications" do + call + + expect(Notification).to exist(id: other_notification.id) + end + end end end end