Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions app/models/notifications/scopes/visible.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,31 @@ module Visible
# * the user is the recipient
# * the user has the permission to see the associated resource
#
# As currently only notifications for work packages exist, the implementation is work package specific.
# Notifications exist for work packages and documents. Each resource type is
# checked against the visibility scope of its own model.
def visible(user)
recipient(user)
.where(resource_type: "WorkPackage", resource_id: WorkPackage.visible(user).select(:id))
.merge(visible_resources(user))
end

# The resource types notifications can be visible for, mapped to the scope
# of resources of that type visible to the user.
def visible_resource_scopes(user)
{
"WorkPackage" => WorkPackage.visible(user),
"Document" => Document.visible(user)
}
end

private

# One `resource_type = ... AND resource_id IN (subquery)` condition per resource type,
# combined by OR. Built on an unscoped relation so that the conditions of the scope
# `visible` is called on are not repeated within each OR branch.
def visible_resources(user)
visible_resource_scopes(user)
.map { |resource_type, scope| unscoped.where(resource_type:, resource_id: scope.select(:id)) }
.reduce(:or)
end
end
end
Expand Down
24 changes: 16 additions & 8 deletions app/models/queries/notifications/filters/project_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@
class Queries::Notifications::Filters::ProjectFilter < Queries::Notifications::Filters::NotificationFilter
include Queries::Filters::Shared::ProjectFilter::Optional

# This is currently work package specific same as all the other parts of the NotificationQuery
self.model = WorkPackage
# Notifications are matched if their resource (e.g. a work package or a document)
# matches the filter on its project. The condition is evaluated per resource type
# within a subquery so that each operator (including the negating ones) is applied
# only to the resource the notification is about.
def where
conditions = Queries::Notifications::ResourceProject.resource_classes.map do |klass|
<<~SQL.squish
(#{Notification.table_name}.resource_type = '#{klass.name}'
AND #{Notification.table_name}.resource_id IN (
SELECT #{klass.table_name}.id
FROM #{klass.table_name}
WHERE #{operator_strategy.sql_for_field(values, klass.table_name, :project_id)}
))
SQL
end

def joins
<<~SQL.squish
JOIN #{WorkPackage.table_name}
ON #{WorkPackage.table_name}.id = #{Notification.table_name}.resource_id
AND #{Notification.table_name}.resource_type = 'WorkPackage'
SQL
"(#{conditions.join(' OR ')})"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@

class Queries::Notifications::Filters::ResourceIdFilter < Queries::Notifications::Filters::NotificationFilter
def allowed_values
WorkPackage
.visible(User.current)
.pluck(:id, :id)
Notification
.visible_resource_scopes(User.current)
.values
.flat_map { |scope| scope.pluck(:id, :id) }
.uniq
end

def type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

class Queries::Notifications::Filters::ResourceTypeFilter < Queries::Notifications::Filters::NotificationFilter
def allowed_values
[[WorkPackage.name, WorkPackage.name]]
Notification.visible_resource_scopes(User.current).keys.map { |resource_type| [resource_type, resource_type] }
end

def type
Expand Down
12 changes: 2 additions & 10 deletions app/models/queries/notifications/group_bys/group_by_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,10 @@ def self.key
:project
end

def joins
# Only Notifications for work_packages are currently supported via the query.
# E.g. the visible statement used in the query is WorkPackage specific.
<<~SQL.squish
JOIN work_packages
ON notifications.resource_id = work_packages.id
AND notifications.resource_type = 'WorkPackage'
SQL
end
delegate :joins, to: Queries::Notifications::ResourceProject

def name
:project_id
Arel.sql(Queries::Notifications::ResourceProject.project_id_sql)
end

def association_class
Expand Down
18 changes: 10 additions & 8 deletions app/models/queries/notifications/orders/project_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,27 @@
class Queries::Notifications::Orders::ProjectOrder < Queries::Orders::Base
self.model = Notification

PROJECTS_ALIAS = "notification_projects"

def self.key
:project
end

def joins
<<~SQL.squish
JOIN #{WorkPackage.table_name} work_packages_order
ON work_packages_order.id = #{Notification.table_name}.resource_id
AND #{Notification.table_name}.resource_type = 'WorkPackage'
JOIN #{Project.table_name}
ON #{Project.table_name}.id = work_packages_order.project_id
SQL
[
Queries::Notifications::ResourceProject.joins,
<<~SQL.squish
LEFT OUTER JOIN #{Project.table_name} #{PROJECTS_ALIAS}
ON #{PROJECTS_ALIAS}.id = #{Queries::Notifications::ResourceProject.project_id_sql}
SQL
]
end

protected

def order(scope)
with_raise_on_invalid do
order_string = "#{Project.table_name}.name"
order_string = "#{PROJECTS_ALIAS}.name"
order_string += " DESC" if direction == :desc

scope.order(order_string)
Expand Down
69 changes: 69 additions & 0 deletions app/models/queries/notifications/resource_project.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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.
#++

# The project of a notification is the project of its resource. Notifications are
# created for resources of different types (work packages and documents), all of which
# store their project in a `project_id` column.
module Queries::Notifications::ResourceProject
module_function

def resource_classes
[WorkPackage, Document]
end

# LEFT OUTER JOINs every resource table under an alias of its own. Only the table
# matching the notification's resource_type will have a matching row.
# Identical in every query part using it so that Rails joins the tables only once
# when a query is filtered, grouped and ordered by project at the same time.
def joins
resource_classes
.map do |klass|
table_alias = table_alias_for(klass)

<<~SQL.squish
LEFT OUTER JOIN #{klass.table_name} #{table_alias}
ON #{table_alias}.id = #{Notification.table_name}.resource_id
AND #{Notification.table_name}.resource_type = '#{klass.name}'
SQL
end
.join(" ")
end

# The project id of the notification's resource. Requires the `joins`.
def project_id_sql
columns = resource_classes.map { |klass| "#{table_alias_for(klass)}.project_id" }

"COALESCE(#{columns.join(', ')})"
end

def table_alias_for(klass)
"notification_#{klass.table_name}"
end
end
1 change: 1 addition & 0 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ en:
label_description: "Description"
label_details: "Details"
label_display: "Display"
label_document: "Document"
label_drop_files: "Drop files here to attach files."
label_drop_folders_hint: You cannot upload folders as an attachment. Please select single files.
label_drop_or_click_files: "Drop files here or click to attach files."
Expand Down
14 changes: 8 additions & 6 deletions docs/api/apiv3/components/schemas/notification_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ properties:
type: object
required:
- project
- resource
properties:
actor:
$ref: './user_model.yml'
Expand All @@ -58,6 +57,9 @@ properties:
activity:
$ref: './activity_model.yml'
resource:
description: |-
The resource of the notification. Only embedded for work packages.
Documents are only linked via `_links.resource`.
oneOf:
- $ref: './work_package_model.yml'
_links:
Expand All @@ -66,7 +68,6 @@ properties:
- self
- project
- actor
- activity
- resource
properties:
self:
Expand Down Expand Up @@ -105,14 +106,15 @@ properties:
allOf:
- "$ref": "./link.yml"
- description: |-
The linked resource of the notification, if any.
The resource the notification is about. This can be either a work package or a document.
The link's title is the work package's subject or the document's title respectively.

**Resource**: Polymorphic
**Resource**: WorkPackage or Document
activity:
allOf:
- "$ref": "./link.yml"
- description: |-
The journal activity, if the notification originated from a journal entry. This might be null in
case no activity triggered the notification.
The journal activity, if the notification originated from a journal entry. Only present
if the notification was triggered by an activity, e.g. not for date alerts or mentions in documents.

**Resource**: Activity
2 changes: 1 addition & 1 deletion docs/api/apiv3/paths/notifications.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ get:

+ resourceId: Filter by the id of the resource the notification was created for. Ideally used together with the `resourceType` filter.

+ resourceType: Filter by the type of the resource the notification was created for. Ideally used together with the `resourceId` filter.
+ resourceType: Filter by the type of the resource the notification was created for, i.e. `WorkPackage` or `Document`. Ideally used together with the `resourceId` filter.
example: '[{ "readIAN": { "operator": "=", "values": ["t"] } }]'
required: false
schema:
Expand Down
5 changes: 4 additions & 1 deletion docs/api/apiv3/tags/notifications.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ 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.

Besides work packages, notifications can also concern documents (currently when a user is mentioned in a document).
In that case, the document is only linked as the notification's resource, not embedded.

Listing notifications only returns in-app notifications, not notifications that are only send via email.

## Actions
Expand All @@ -21,7 +24,7 @@ description: |-
| project | The project containing the resource | Project | not null | READ | |
| actor | The user that caused the notification | User | | READ | optional |
| resource | The resource the notification belongs to | Polymorphic | not null | READ | |
| activity | The journal the notification belongs to | Polymorphic | | READ | optional |
| activity | The journal the notification belongs to | Polymorphic | | READ | only if triggered by an activity |
| details | A list of objects including detailed information | Polymorphic | | READ | optional |

## Local Properties
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/core/path-helper/path-helper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class PathHelperService {
return `${this.staticBase}/attribute_help_texts/${id.toString()}/show_dialog`;
}

public documentPath(documentId:string|number):string {
return `${this.staticBase}/documents/${documentId.toString()}`;
}

public fileLinksPath():string {
return `${this.api.v3.apiV3Base}/file_links`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
[aggregatedNotifications]="records"
attr.data-test-selector="op-ian-notification-item-{{records[0].id}}"
[attr.data-qa-ian-read]="records[0].readIAN === true || undefined"
[attr.data-qa-ian-selected]="notificationMatchesSelectedWorkPackage(records[0], selectedWorkPackage$ | async)"
[attr.data-qa-ian-selected]="notificationMatchesSelectedWorkPackage(records[0], selectedWorkPackage$ | async) || undefined"
/>
</cdk-virtual-scroll-viewport>
} @else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { IanBellService } from 'core-app/features/in-app-notifications/bell/stat
import { imagePath } from 'core-app/shared/helpers/images/path-helper';
import { UrlParamsService } from 'core-app/core/navigation/url-params.service';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { notificationWorkPackageId } from 'core-app/features/in-app-notifications/notification-resource-id';
import {
ColorsService,
} from 'core-app/shared/components/colors/colors.service';
Expand Down Expand Up @@ -182,9 +183,12 @@ export class InAppNotificationCenterComponent implements OnInit {
}

notificationMatchesSelectedWorkPackage(notification:INotification, selected:string|null):boolean {
const href = notification._links.resource?.href;
const workPackageId = href ? idFromLink(href) : null;
const workPackage = workPackageId ? this.apiV3.work_packages.cache.current(workPackageId) : null;
const workPackageId = notificationWorkPackageId(notification);
if (!workPackageId) {
return false;
}

const workPackage = this.apiV3.work_packages.cache.current(workPackageId);

return selected === workPackageId || selected === workPackage?.displayId;
}
Expand Down
Loading
Loading