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) %>
+
+ <% end %>
+
+
+
+ <%= @document.title %>
+
+ <%= placeholder_cell("12px", vertical: true) %>
+