FIX: Display assignments in user menu properly

Currently, we display a mix of topics and notifications in the user menu
assignments tab. This has a number of issues like having to maintain
hard to understand code instead of simply relying on the notifications
system we have, we can’t display more than one assignment per topic and
it’s not clear if an assignment is for a topic or a post nor if it’s a
group assignment or an individual one.

This patch addresses those issues by relying on the notifications system
we’re using for most of the other user menu tabs instead of a custom
implementation. This led to some heavy refactoring but it was
worthwhile as things are a bit more normalized and easier to reason
about. Instead of serializing topics with different attributes to access
various assignments, we now simply return a notification for each
existing assignment.

The UI changed a bit: tooltips are now explaining if the assignment is
for a topic or a post and if it’s for an individual or a group. Icons
are also properly set (so no more individual icon for group assignments)
and the assigned group name is displayed.

The background jobs signatures changed a bit (`assignment_id` is now
needed for the unassign job) so when deploying this patch, it’s expected
to have some jobs failing. That’s why a post-migration has been written
to handle the creation of missing notifications and the deletion of
extra notifications too.
This commit is contained in:
Loïc Guitaut 2023-09-06 18:10:31 +02:00 committed by Loïc Guitaut
parent 20cdd5ae87
commit 80604e9012
25 changed files with 802 additions and 917 deletions

View File

@ -159,43 +159,6 @@ module DiscourseAssign
} }
end end
def user_menu_assigns
assign_notifications =
Notification.unread_type(current_user, Notification.types[:assigned], user_menu_limit)
if assign_notifications.size < user_menu_limit
opts = {}
ignored_assignment_ids =
assign_notifications.filter_map { |notification| notification.data_hash[:assignment_id] }
opts[:ignored_assignment_ids] = ignored_assignment_ids if ignored_assignment_ids.present?
assigns_list =
TopicQuery.new(
current_user,
per_page: user_menu_limit - assign_notifications.size,
).list_messages_assigned(current_user, ignored_assignment_ids)
end
if assign_notifications.present?
serialized_notifications =
ActiveModel::ArraySerializer.new(
assign_notifications,
each_serializer: NotificationSerializer,
scope: guardian,
)
end
if assigns_list
serialized_assigns =
serialize_data(assigns_list, TopicListSerializer, scope: guardian, root: false)[:topics]
end
render json: {
notifications: serialized_notifications || [],
topics: serialized_assigns || [],
}
end
private private
def translate_failure(reason, assign_to) def translate_failure(reason, assign_to)
@ -261,10 +224,6 @@ module DiscourseAssign
raise Discourse::InvalidAccess.new unless current_user.can_assign? raise Discourse::InvalidAccess.new unless current_user.can_assign?
end end
def user_menu_limit
UsersController::USER_MENU_LIST_LIMIT
end
def recent_assignees def recent_assignees
User User
.where("users.id <> ?", current_user.id) .where("users.id <> ?", current_user.id)

View File

@ -3,79 +3,8 @@
module Jobs module Jobs
class AssignNotification < ::Jobs::Base class AssignNotification < ::Jobs::Base
def execute(args) def execute(args)
raise Discourse::InvalidParameters.new(:topic_id) if args[:topic_id].nil?
raise Discourse::InvalidParameters.new(:post_id) if args[:post_id].nil?
raise Discourse::InvalidParameters.new(:assigned_to_id) if args[:assigned_to_id].nil?
raise Discourse::InvalidParameters.new(:assigned_to_type) if args[:assigned_to_type].nil?
raise Discourse::InvalidParameters.new(:assigned_by_id) if args[:assigned_by_id].nil?
raise Discourse::InvalidParameters.new(:assignment_id) if args[:assignment_id].nil? raise Discourse::InvalidParameters.new(:assignment_id) if args[:assignment_id].nil?
Assignment.find(args[:assignment_id]).create_missing_notifications!
if args[:skip_small_action_post].nil?
raise Discourse::InvalidParameters.new(:skip_small_action_post)
end
topic = Topic.find(args[:topic_id])
post = Post.find(args[:post_id])
assigned_by = User.find(args[:assigned_by_id])
assigned_to =
(
if args[:assigned_to_type] == "User"
User.find(args[:assigned_to_id])
else
Group.find(args[:assigned_to_id])
end
)
assigned_to_users = args[:assigned_to_type] == "User" ? [assigned_to] : assigned_to.users
assigned_to_users.each do |user|
Assigner.publish_topic_tracking_state(topic, user.id)
next if assigned_by == user
assigned_to_user = args[:assigned_to_type] == "User"
PostAlerter.new(post).create_notification_alert(
user: user,
post: post,
username: assigned_by.username,
notification_type: Notification.types[:assigned] || Notification.types[:custom],
excerpt:
I18n.t(
(
if assigned_to_user
"discourse_assign.topic_assigned_excerpt"
else
"discourse_assign.topic_group_assigned_excerpt"
end
),
title: topic.title,
group: assigned_to.name,
locale: user.effective_locale,
),
)
next if args[:skip_small_action_post]
Notification.create!(
notification_type: Notification.types[:assigned] || Notification.types[:custom],
user_id: user.id,
topic_id: topic.id,
post_number: post.post_number,
high_priority: true,
data: {
message:
(
if assigned_to_user
"discourse_assign.assign_notification"
else
"discourse_assign.assign_group_notification"
end
),
display_username: assigned_to_user ? assigned_by.username : assigned_to.name,
topic_title: topic.title,
assignment_id: args[:assignment_id],
}.to_json,
)
end
end end
end end
end end

View File

@ -3,34 +3,18 @@
module Jobs module Jobs
class UnassignNotification < ::Jobs::Base class UnassignNotification < ::Jobs::Base
def execute(args) def execute(args)
raise Discourse::InvalidParameters.new(:topic_id) if args[:topic_id].nil? %i[topic_id assigned_to_id assigned_to_type assignment_id].each do |argument|
raise Discourse::InvalidParameters.new(:assigned_to_id) if args[:assigned_to_id].nil? raise Discourse::InvalidParameters.new(argument) if args[argument].nil?
raise Discourse::InvalidParameters.new(:assigned_to_type) if args[:assigned_to_type].nil?
topic = Topic.find(args[:topic_id])
assigned_to_users =
(
if args[:assigned_to_type] == "User"
[User.find(args[:assigned_to_id])]
else
Group.find(args[:assigned_to_id]).users
end
)
assigned_to_users.each do |user|
Assigner.publish_topic_tracking_state(topic, user.id)
Notification
.where(
notification_type: Notification.types[:assigned] || Notification.types[:custom],
user_id: user.id,
topic_id: topic.id,
)
.where(
"data like '%discourse_assign.assign_notification%' OR data like '%discourse_assign.assign_group_notification%'",
)
.destroy_all
end end
assignment = Assignment.new(args.slice(:topic_id, :assigned_to_id, :assigned_to_type))
assignment.assigned_users.each do |user|
Assigner.publish_topic_tracking_state(assignment.topic, user.id)
end
Notification
.for_assignment(args[:assignment_id])
.where(user: assignment.assigned_users, topic: assignment.topic)
.destroy_all
end end
end end
end end

View File

@ -15,7 +15,9 @@ class Assignment < ActiveRecord::Base
) )
end end
scope :active_for_group, ->(group) { where(assigned_to: group, active: true) } scope :active_for_group, ->(group) { active.where(assigned_to: group) }
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
before_validation :default_status before_validation :default_status
@ -38,11 +40,31 @@ class Assignment < ActiveRecord::Base
end end
def assigned_to_user? def assigned_to_user?
assigned_to_type == "User" assigned_to.is_a?(User)
end end
def assigned_to_group? def assigned_to_group?
assigned_to_type == "Group" assigned_to.is_a?(Group)
end
def assigned_users
Array.wrap(assigned_to.try(:users) || assigned_to)
end
def post
return target.posts.find_by(post_number: 1) if target.is_a?(Topic)
target
end
def create_missing_notifications!(mark_as_read: false)
assigned_users.each do |user|
next if user.notifications.for_assignment(self).exists?
DiscourseAssign::CreateNotification.call(
assignment: self,
user: user,
mark_as_read: mark_as_read || assigned_by_user == user,
)
end
end end
private private

View File

@ -1,10 +1,5 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
import { ajax } from "discourse/lib/ajax";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import Notification from "discourse/models/notification";
import Topic from "discourse/models/topic";
import I18n from "I18n"; import I18n from "I18n";
import UserMenuAssignItem from "../../lib/user-menu/assign-item";
import UserMenuAssignsListEmptyState from "./assigns-list-empty-state"; import UserMenuAssignsListEmptyState from "./assigns-list-empty-state";
export default class UserMenuAssignNotificationsList extends UserMenuNotificationsList { export default class UserMenuAssignNotificationsList extends UserMenuNotificationsList {
@ -56,25 +51,10 @@ export default class UserMenuAssignNotificationsList extends UserMenuNotificatio
} }
async fetchItems() { async fetchItems() {
const data = await ajax("/assign/user-menu-assigns.json"); // sorting by `data.message` length to group single user assignments and
const content = []; // group assignments, then by `created_at` to keep chronological order.
return (await super.fetchItems())
const notifications = data.notifications.map((n) => Notification.create(n)); .sortBy("notification.data.message", "notification.created_at")
await Notification.applyTransformations(notifications); .reverse();
notifications.forEach((notification) => {
content.push(
new UserMenuNotificationItem({
notification,
currentUser: this.currentUser,
siteSettings: this.siteSettings,
site: this.site,
})
);
});
const topics = data.topics.map((t) => Topic.create(t));
await Topic.applyTransformations(topics);
content.push(...topics.map((assign) => new UserMenuAssignItem({ assign })));
return content;
} }
} }

View File

@ -1,4 +1,7 @@
import { htmlSafe } from "@ember/template";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import { emojiUnescape } from "discourse/lib/text";
import I18n from "I18n";
import UserMenuAssignNotificationsList from "../components/user-menu/assigns-list"; import UserMenuAssignNotificationsList from "../components/user-menu/assigns-list";
export default { export default {
@ -6,16 +9,74 @@ export default {
initialize(container) { initialize(container) {
withPluginApi("1.2.0", (api) => { withPluginApi("1.2.0", (api) => {
if (api.registerUserMenuTab) { const siteSettings = container.lookup("service:site-settings");
const siteSettings = container.lookup("service:site-settings"); if (!siteSettings.assign_enabled) {
if (!siteSettings.assign_enabled) { return;
return; }
}
const currentUser = api.getCurrentUser(); const currentUser = api.getCurrentUser();
if (!currentUser?.can_assign) { if (!currentUser?.can_assign) {
return; return;
} }
if (api.registerNotificationTypeRenderer) {
api.registerNotificationTypeRenderer(
"assigned",
(NotificationItemBase) => {
return class extends NotificationItemBase {
get linkTitle() {
if (this.isGroup()) {
return I18n.t(
`user.assigned_to_group.${this.postOrTopic()}`,
{
group_name: this.notification.data.display_username,
}
);
}
return I18n.t(`user.assigned_to_you.${this.postOrTopic()}`);
}
get icon() {
return this.isGroup() ? "group-plus" : "user-plus";
}
get label() {
if (!this.isGroup()) {
return "";
}
return this.notification.data.display_username;
}
get description() {
return htmlSafe(
emojiUnescape(
I18n.t(
`user.assignment_description.${this.postOrTopic()}`,
{
topic_title: this.notification.fancy_title,
post_number: this.notification.post_number,
}
)
)
);
}
isGroup() {
return (
this.notification.data.message ===
"discourse_assign.assign_group_notification"
);
}
postOrTopic() {
return this.notification.post_number === 1 ? "topic" : "post";
}
};
}
);
}
if (api.registerUserMenuTab) {
api.registerUserMenuTab((UserMenuTab) => { api.registerUserMenuTab((UserMenuTab) => {
return class extends UserMenuTab { return class extends UserMenuTab {
id = "assign-list"; id = "assign-list";

View File

@ -1,3 +1,4 @@
import { getOwner } from "@ember/application";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { h } from "virtual-dom"; import { h } from "virtual-dom";
@ -7,7 +8,6 @@ import { withPluginApi } from "discourse/lib/plugin-api";
import { registerTopicFooterDropdown } from "discourse/lib/register-topic-footer-dropdown"; import { registerTopicFooterDropdown } from "discourse/lib/register-topic-footer-dropdown";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import RawHtml from "discourse/widgets/raw-html"; import RawHtml from "discourse/widgets/raw-html";
import { getOwner } from "discourse-common/lib/get-owner";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { iconHTML, iconNode } from "discourse-common/lib/icon-library"; import { iconHTML, iconNode } from "discourse-common/lib/icon-library";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";

View File

@ -1,59 +0,0 @@
import { htmlSafe } from "@ember/template";
import { emojiUnescape } from "discourse/lib/text";
import UserMenuBaseItem from "discourse/lib/user-menu/base-item";
import { postUrl } from "discourse/lib/utilities";
import I18n from "I18n";
const ICON = "user-plus";
const GROUP_ICON = "group-plus";
export default class UserMenuAssignItem extends UserMenuBaseItem {
constructor({ assign }) {
super(...arguments);
this.assign = assign;
}
get className() {
return "assign";
}
get linkHref() {
return postUrl(
this.assign.slug,
this.assign.id,
(this.assign.last_read_post_number || 0) + 1
);
}
get linkTitle() {
if (this.assign.assigned_to_group) {
return I18n.t("user.assigned_to_group", {
group_name:
this.assign.assigned_to_group.full_name ||
this.assign.assigned_to_group.name,
});
} else {
return I18n.t("user.assigned_to_you");
}
}
get icon() {
if (this.assign.assigned_to_group) {
return GROUP_ICON;
} else {
return ICON;
}
}
get label() {
return null;
}
get description() {
return htmlSafe(emojiUnescape(this.assign.fancy_title));
}
get topicId() {
return this.assign.id;
}
}

View File

@ -32,8 +32,6 @@ en:
# assign_post_to_multiple used in list form, example: "Assigned topic to username0, [#2 to username1], [#10 to username2]" # assign_post_to_multiple used in list form, example: "Assigned topic to username0, [#2 to username1], [#10 to username2]"
assign_post_to_multiple: "#%{post_number} to %{username}" assign_post_to_multiple: "#%{post_number} to %{username}"
assigned_to_w_ellipsis: "Assigned to..." assigned_to_w_ellipsis: "Assigned to..."
assign_notification: "<p><span>%{username}</span> %{description}</p>"
assign_group_notification: "<p><span>%{username}</span> %{description}</p>"
unassign: unassign:
title: "Unassign" title: "Unassign"
title_w_ellipsis: "Unassign..." title_w_ellipsis: "Unassign..."
@ -97,8 +95,15 @@ en:
<br><br> <br><br>
To assign a topic or message to yourself or to someone else, look for the %{icon} assign button at the bottom. To assign a topic or message to yourself or to someone else, look for the %{icon} assign button at the bottom.
dismiss_assigned_tooltip: "Mark all unread assign notifications as read" dismiss_assigned_tooltip: "Mark all unread assign notifications as read"
assigned_to_group: "assigned to %{group_name}" assigned_to_group:
assigned_to_you: "assigned to you" post: "post assigned to %{group_name}"
topic: "topic assigned to %{group_name}"
assigned_to_you:
post: "post assigned to you"
topic: "topic assigned to you"
assignment_description:
post: "%{topic_title} (#%{post_number})"
topic: "%{topic_title}"
admin: admin:
web_hooks: web_hooks:
assign_event: assign_event:

View File

@ -7,7 +7,6 @@ DiscourseAssign::Engine.routes.draw do
get "/suggestions" => "assign#suggestions" get "/suggestions" => "assign#suggestions"
get "/assigned" => "assign#assigned" get "/assigned" => "assign#assigned"
get "/members/:group_name" => "assign#group_members" get "/members/:group_name" => "assign#group_members"
get "/user-menu-assigns" => "assign#user_menu_assigns"
end end
Discourse::Application.routes.draw do Discourse::Application.routes.draw do

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class EnsureNotificationsConsistency < ActiveRecord::Migration[7.0]
def up
Notification
.assigned
.joins(
"LEFT OUTER JOIN assignments ON assignments.id = ((notifications.data::jsonb)->'assignment_id')::int",
)
.where(assignments: { id: nil })
.or(Assignment.inactive)
.destroy_all
Assignment
.active
.left_joins(:topic)
.where.not(topics: { id: nil })
.find_each do |assignment|
next if !assignment.target || !assignment.assigned_to
assignment.create_missing_notifications!(mark_as_read: true)
end
end
def down
end
end

View File

@ -239,11 +239,8 @@ class ::Assigner
end end
@target.assignment.update!(note: note, status: status) @target.assignment.update!(note: note, status: status)
queue_notification(@target.assignment)
queue_notification(assign_to, skip_small_action_post, @target.assignment) publish_assignment(@target.assignment, assign_to, note, status)
assignment = @target.assignment
publish_assignment(assignment, assign_to, note, status)
# email is skipped, for now # email is skipped, for now
@ -276,31 +273,28 @@ class ::Assigner
skip_small_action_post = skip_small_action_post || no_assignee_change?(assign_to) skip_small_action_post = skip_small_action_post || no_assignee_change?(assign_to)
if topic.assignment.present? if @target.assignment
Jobs.enqueue( Jobs.enqueue(
:unassign_notification, :unassign_notification,
topic_id: topic.id, topic_id: topic.id,
assigned_to_id: topic.assignment.assigned_to_id, assigned_to_id: @target.assignment.assigned_to_id,
assigned_to_type: topic.assignment.assigned_to_type, assigned_to_type: @target.assignment.assigned_to_type,
assignment_id: @target.assignment.id,
) )
@target.assignment.destroy!
end end
@target.assignment&.destroy!
assignment = assignment =
@target.create_assignment!( @target.create_assignment!(
assigned_to_id: assign_to.id, assigned_to: assign_to,
assigned_to_type: assigned_to_type, assigned_by_user: @assigned_by,
assigned_by_user_id: @assigned_by.id, topic: topic,
topic_id: topic.id,
note: note, note: note,
status: status, status: status,
) )
first_post.publish_change_to_clients!(:revised, reload_topic: true) first_post.publish_change_to_clients!(:revised, reload_topic: true)
queue_notification(assignment)
queue_notification(assign_to, skip_small_action_post, assignment)
publish_assignment(assignment, assign_to, note, status) publish_assignment(assignment, assign_to, note, status)
if assignment.assigned_to_user? if assignment.assigned_to_user?
@ -370,6 +364,7 @@ class ::Assigner
topic_id: topic.id, topic_id: topic.id,
assigned_to_id: assignment.assigned_to.id, assigned_to_id: assignment.assigned_to.id,
assigned_to_type: assignment.assigned_to_type, assigned_to_type: assignment.assigned_to_type,
assignment_id: assignment.id,
) )
assigned_to = assignment.assigned_to assigned_to = assignment.assigned_to
@ -461,17 +456,8 @@ class ::Assigner
@guardian ||= Guardian.new(@assigned_by) @guardian ||= Guardian.new(@assigned_by)
end end
def queue_notification(assign_to, skip_small_action_post, assignment) def queue_notification(assignment)
Jobs.enqueue( Jobs.enqueue(:assign_notification, assignment_id: assignment.id)
:assign_notification,
topic_id: topic.id,
post_id: topic_target? ? first_post.id : @target.id,
assigned_to_id: assign_to.id,
assigned_to_type: assign_to.is_a?(User) ? "User" : "Group",
assigned_by_id: @assigned_by.id,
skip_small_action_post: skip_small_action_post,
assignment_id: assignment.id,
)
end end
def add_small_action_post(action_code, assign_to, text) def add_small_action_post(action_code, assign_to, text)

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
module DiscourseAssign
class CreateNotification
class UserAssignment
attr_reader :assignment
def initialize(assignment)
@assignment = assignment
end
def excerpt_key
"discourse_assign.topic_assigned_excerpt"
end
def notification_message
"discourse_assign.assign_notification"
end
def display_username
assignment.assigned_by_user.username
end
end
class GroupAssignment < UserAssignment
def excerpt_key
"discourse_assign.topic_group_assigned_excerpt"
end
def notification_message
"discourse_assign.assign_group_notification"
end
def display_username
assignment.assigned_to.name
end
end
def self.call(...)
new(...).call
end
attr_reader :assignment, :user, :mark_as_read, :assignment_type
alias mark_as_read? mark_as_read
delegate :topic,
:post,
:assigned_by_user,
:assigned_to,
:created_at,
:updated_at,
:assigned_to_user?,
:id,
to: :assignment,
private: true
delegate :excerpt_key,
:notification_message,
:display_username,
to: :assignment_type,
private: true
def initialize(assignment:, user:, mark_as_read:)
@assignment = assignment
@user = user
@mark_as_read = mark_as_read
@assignment_type =
"#{self.class}::#{assignment.assigned_to.class}Assignment".constantize.new(assignment)
end
def call
Assigner.publish_topic_tracking_state(topic, user.id)
unless mark_as_read?
PostAlerter.new(post).create_notification_alert(
user: user,
post: post,
username: assigned_by_user.username,
notification_type: Notification.types[:assigned],
excerpt:
I18n.t(
excerpt_key,
title: topic.title,
group: assigned_to.name,
locale: user.effective_locale,
),
)
end
user.notifications.assigned.create!(
created_at: created_at,
updated_at: updated_at,
topic: topic,
post_number: post.post_number,
high_priority: true,
read: mark_as_read?,
data: {
message: notification_message,
display_username: display_username,
topic_title: topic.title,
assignment_id: id,
}.to_json,
)
end
end
end

View File

@ -2,12 +2,15 @@
module DiscourseAssign module DiscourseAssign
module GroupExtension module GroupExtension
def self.prepended(base) extend ActiveSupport::Concern
base.class_eval do
scope :assignable, prepended do
->(user) do has_many :assignments, as: :assigned_to
where(
"assignable_level in (:levels) OR scope :assignable,
->(user) do
where(
"assignable_level in (:levels) OR
( (
assignable_level = #{Group::ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( assignable_level = #{Group::ALIAS_LEVELS[:members_mods_and_admins]} AND id in (
SELECT group_id FROM group_users WHERE user_id = :user_id) SELECT group_id FROM group_users WHERE user_id = :user_id)
@ -15,11 +18,10 @@ module DiscourseAssign
assignable_level = #{Group::ALIAS_LEVELS[:owners_mods_and_admins]} AND id in ( assignable_level = #{Group::ALIAS_LEVELS[:owners_mods_and_admins]} AND id in (
SELECT group_id FROM group_users WHERE user_id = :user_id AND owner IS TRUE) SELECT group_id FROM group_users WHERE user_id = :user_id AND owner IS TRUE)
)", )",
levels: alias_levels(user), levels: alias_levels(user),
user_id: user&.id, user_id: user&.id,
) )
end end
end
end end
end end
end end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module DiscourseAssign
module NotificationExtension
extend ActiveSupport::Concern
prepended do
scope :assigned, -> { where(notification_type: Notification.types[:assigned]) }
scope :for_assignment,
->(assignment) do
assigned.where("((data::jsonb)->'assignment_id')::int IN (?)", assignment)
end
end
end
end

View File

@ -26,10 +26,12 @@ after_initialize do
require_relative "app/jobs/regular/unassign_notification" require_relative "app/jobs/regular/unassign_notification"
require_relative "app/jobs/scheduled/enqueue_reminders" require_relative "app/jobs/scheduled/enqueue_reminders"
require_relative "lib/assigner" require_relative "lib/assigner"
require_relative "lib/discourse_assign/create_notification"
require_relative "lib/discourse_assign/discourse_calendar" require_relative "lib/discourse_assign/discourse_calendar"
require_relative "lib/discourse_assign/group_extension" require_relative "lib/discourse_assign/group_extension"
require_relative "lib/discourse_assign/helpers" require_relative "lib/discourse_assign/helpers"
require_relative "lib/discourse_assign/list_controller_extension" require_relative "lib/discourse_assign/list_controller_extension"
require_relative "lib/discourse_assign/notification_extension"
require_relative "lib/discourse_assign/post_extension" require_relative "lib/discourse_assign/post_extension"
require_relative "lib/discourse_assign/topic_extension" require_relative "lib/discourse_assign/topic_extension"
require_relative "lib/discourse_assign/web_hook_extension" require_relative "lib/discourse_assign/web_hook_extension"
@ -38,11 +40,12 @@ after_initialize do
require_relative "lib/topic_assigner" require_relative "lib/topic_assigner"
reloadable_patch do |plugin| reloadable_patch do |plugin|
Group.class_eval { prepend DiscourseAssign::GroupExtension } Group.prepend(DiscourseAssign::GroupExtension)
ListController.class_eval { prepend DiscourseAssign::ListControllerExtension } ListController.prepend(DiscourseAssign::ListControllerExtension)
Post.class_eval { prepend DiscourseAssign::PostExtension } Post.prepend(DiscourseAssign::PostExtension)
Topic.class_eval { prepend DiscourseAssign::TopicExtension } Topic.prepend(DiscourseAssign::TopicExtension)
WebHook.class_eval { prepend DiscourseAssign::WebHookExtension } WebHook.prepend(DiscourseAssign::WebHookExtension)
Notification.prepend(DiscourseAssign::NotificationExtension)
end end
register_group_param(:assignable_level) register_group_param(:assignable_level)
@ -201,7 +204,8 @@ after_initialize do
allowed_access = SiteSetting.assigns_public || can_assign allowed_access = SiteSetting.assigns_public || can_assign
if allowed_access && topics.length > 0 if allowed_access && topics.length > 0
assignments = Assignment.strict_loading.where(topic: topics, active: true).includes(:target) assignments =
Assignment.strict_loading.active.where(topic: topics).includes(:target, :assigned_to)
assignments_map = assignments.group_by(&:topic_id) assignments_map = assignments.group_by(&:topic_id)
user_ids = user_ids =
@ -836,20 +840,12 @@ after_initialize do
next if !info[:group] next if !info[:group]
Assignment Assignment
.where(topic_id: topic.id, active: false) .inactive
.where(topic: topic)
.find_each do |assignment| .find_each do |assignment|
next unless assignment.target next unless assignment.target
assignment.update!(active: true) assignment.update!(active: true)
Jobs.enqueue( Jobs.enqueue(:assign_notification, assignment_id: assignment.id)
:assign_notification,
topic_id: topic.id,
post_id: assignment.target_type.is_a?(Topic) ? topic.first_post.id : assignment.target.id,
assigned_to_id: assignment.assigned_to_id,
assigned_to_type: assignment.assigned_to_type,
assigned_by_id: assignment.assigned_by_user_id,
skip_small_action_post: true,
assignment_id: assignment.id,
)
end end
end end
@ -863,32 +859,30 @@ after_initialize do
next if !info[:group] next if !info[:group]
Assignment Assignment
.where(topic_id: topic.id, active: true) .active
.where(topic: topic)
.find_each do |assignment| .find_each do |assignment|
assignment.update!(active: false) assignment.update!(active: false)
Jobs.enqueue( Jobs.enqueue(
:unassign_notification, :unassign_notification,
topic_id: topic.id, topic_id: topic.id,
assigned_to_id: assignment.assigned_to.id, assigned_to_id: assignment.assigned_to_id,
assigned_to_type: assignment.assigned_to_type, assigned_to_type: assignment.assigned_to_type,
assignment_id: assignment.id,
) )
end end
end end
on(:user_removed_from_group) do |user, group| on(:user_added_to_group) do |user, group, automatic:|
assign_allowed_groups = SiteSetting.assign_allowed_on_groups.split("|").map(&:to_i) group.assignments.active.find_each do |assignment|
Jobs.enqueue(:assign_notification, assignment_id: assignment.id)
if assign_allowed_groups.include?(group.id)
groups = GroupUser.where(user: user).pluck(:group_id)
if (groups & assign_allowed_groups).empty?
topics = Topic.joins(:assignment).where("assignments.assigned_to_id = ?", user.id)
topics.each { |topic| Assigner.new(topic, Discourse.system_user).unassign }
end
end end
end end
on(:user_removed_from_group) do |user, group|
user.notifications.for_assignment(group.assignments.select(:id)).destroy_all
end
on(:post_moved) do |post, original_topic_id| on(:post_moved) do |post, original_topic_id|
assignment = assignment =
Assignment.where(topic_id: original_topic_id, target_type: "Post", target_id: post.id).first Assignment.where(topic_id: original_topic_id, target_type: "Post", target_id: post.id).first

View File

@ -9,6 +9,7 @@ Fabricator(:topic_assignment, class_name: :assignment) do
end end
Fabricator(:post_assignment, class_name: :assignment) do Fabricator(:post_assignment, class_name: :assignment) do
transient :post
topic { |attrs| attrs[:post]&.topic || Fabricate(:topic) } topic { |attrs| attrs[:post]&.topic || Fabricate(:topic) }
target { |attrs| attrs[:post] || Fabricate(:post, topic: attrs[:topic]) } target { |attrs| attrs[:post] || Fabricate(:post, topic: attrs[:topic]) }
target_type "Post" target_type "Post"

View File

@ -4,199 +4,27 @@ require "rails_helper"
RSpec.describe Jobs::AssignNotification do RSpec.describe Jobs::AssignNotification do
describe "#execute" do describe "#execute" do
fab!(:user1) { Fabricate(:user, last_seen_at: 1.day.ago) } subject(:execute_job) { described_class.new.execute(args) }
fab!(:user2) { Fabricate(:user, last_seen_at: 1.day.ago) }
fab!(:topic) { Fabricate(:topic, title: "Basic topic title") }
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:pm_post) { Fabricate(:private_message_post) }
fab!(:pm) { pm_post.topic }
fab!(:assign_allowed_group) { Group.find_by(name: "staff") }
def assert_publish_topic_state(topic, user) let(:args) { { assignment_id: assignment_id } }
message = MessageBus.track_publish("/private-messages/assigned") { yield }.first
expect(message.data[:topic_id]).to eq(topic.id) context "when `assignment_id` is not provided" do
expect(message.user_ids).to eq([user.id]) let(:args) { {} }
end
before { assign_allowed_group.add(user1) } it "raises an error" do
expect { execute_job }.to raise_error(Discourse::InvalidParameters, "assignment_id")
describe "User" do
it "sends notification alert" do
messages =
MessageBus.track_publish("/notification-alert/#{user2.id}") do
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: user2.id,
assigned_to_type: "User",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 1093,
},
)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:excerpt]).to eq(
I18n.t("discourse_assign.topic_assigned_excerpt", title: topic.title),
)
end
it "should publish the right message when private message" do
user = pm.allowed_users.first
assign_allowed_group.add(user)
assert_publish_topic_state(pm, user) do
described_class.new.execute(
{
topic_id: pm.id,
post_id: pm_post.id,
assigned_to_id: pm.allowed_users.first.id,
assigned_to_type: "User",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 9023,
},
)
end
end
it "sends a high priority notification to the assignee" do
Notification.expects(:create!).with(
notification_type: Notification.types[:assigned] || Notification.types[:custom],
user_id: user2.id,
topic_id: topic.id,
post_number: 1,
high_priority: true,
data: {
message: "discourse_assign.assign_notification",
display_username: user1.username,
topic_title: topic.title,
assignment_id: 3194,
}.to_json,
)
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: user2.id,
assigned_to_type: "User",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 3194,
},
)
end end
end end
describe "Group" do context "when `assignment_id` is provided" do
fab!(:user3) { Fabricate(:user, last_seen_at: 1.day.ago) } let(:assignment_id) { Fabricate(:topic_assignment).id }
fab!(:user4) { Fabricate(:user, suspended_till: 1.year.from_now) } let(:assignment) { stub("assignment").responds_like_instance_of(Assignment) }
fab!(:group) { Fabricate(:group, name: "Developers") }
let(:assignment) do
Assignment.create!(topic: topic, assigned_by_user: user1, assigned_to: group)
end
before do before { Assignment.stubs(:find).with(assignment_id).returns(assignment) }
group.add(user2)
group.add(user3)
group.add(user4)
end
it "sends notification alert to all group members" do it "creates missing notifications for the provided assignment" do
messages = assignment.expects(:create_missing_notifications!)
MessageBus.track_publish("/notification-alert/#{user2.id}") do execute_job
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: group.id,
assigned_to_type: "Group",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 7839,
},
)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:excerpt]).to eq(
I18n.t(
"discourse_assign.topic_group_assigned_excerpt",
title: topic.title,
group: group.name,
),
)
messages =
MessageBus.track_publish("/notification-alert/#{user3.id}") do
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: group.id,
assigned_to_type: "Group",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 7763,
},
)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:excerpt]).to eq(
I18n.t(
"discourse_assign.topic_group_assigned_excerpt",
title: topic.title,
group: group.name,
),
)
messages =
MessageBus.track_publish("/notification-alert/#{user4.id}") do
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: group.id,
assigned_to_type: "Group",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 8883,
},
)
end
expect(messages.length).to eq(0)
end
it "sends a high priority notification to all group members" do
[user2, user3, user4].each do |user|
Notification.expects(:create!).with(
notification_type: Notification.types[:assigned] || Notification.types[:custom],
user_id: user.id,
topic_id: topic.id,
post_number: 1,
high_priority: true,
data: {
message: "discourse_assign.assign_group_notification",
display_username: group.name,
topic_title: topic.title,
assignment_id: 9429,
}.to_json,
)
end
described_class.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: group.id,
assigned_to_type: "Group",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 9429,
},
)
end end
end end
end end

View File

@ -22,19 +22,11 @@ RSpec.describe Jobs::UnassignNotification do
end end
describe "User" do describe "User" do
it "deletes notifications" do fab!(:assignment) { Fabricate(:topic_assignment, topic: topic, assigned_to: user2) }
Jobs::AssignNotification.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: user2.id,
assigned_to_type: "User",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 4519,
},
)
before { assignment.create_missing_notifications! }
it "deletes notifications" do
expect { expect {
described_class.new.execute( described_class.new.execute(
{ {
@ -42,6 +34,7 @@ RSpec.describe Jobs::UnassignNotification do
post_id: post.id, post_id: post.id,
assigned_to_id: user2.id, assigned_to_id: user2.id,
assigned_to_type: "User", assigned_to_type: "User",
assignment_id: assignment.id,
}, },
) )
}.to change { user2.notifications.count }.by(-1) }.to change { user2.notifications.count }.by(-1)
@ -58,6 +51,7 @@ RSpec.describe Jobs::UnassignNotification do
post_id: pm_post.id, post_id: pm_post.id,
assigned_to_id: pm.allowed_users.first.id, assigned_to_id: pm.allowed_users.first.id,
assigned_to_type: "User", assigned_to_type: "User",
assignment_id: 4519,
}, },
) )
end end
@ -68,25 +62,17 @@ RSpec.describe Jobs::UnassignNotification do
fab!(:assign_allowed_group) { Group.find_by(name: "staff") } fab!(:assign_allowed_group) { Group.find_by(name: "staff") }
fab!(:user3) { Fabricate(:user) } fab!(:user3) { Fabricate(:user) }
fab!(:group) { Fabricate(:group) } fab!(:group) { Fabricate(:group) }
fab!(:assignment) do
Fabricate(:topic_assignment, topic: topic, assigned_to: group, assigned_by_user: user1)
end
before do before do
group.add(user2) group.add(user2)
group.add(user3) group.add(user3)
assignment.create_missing_notifications!
end end
it "deletes notifications" do it "deletes notifications" do
Jobs::AssignNotification.new.execute(
{
topic_id: topic.id,
post_id: post.id,
assigned_to_id: group.id,
assigned_to_type: "Group",
assigned_by_id: user1.id,
skip_small_action_post: false,
assignment_id: 9281,
},
)
expect { expect {
described_class.new.execute( described_class.new.execute(
{ {
@ -94,6 +80,7 @@ RSpec.describe Jobs::UnassignNotification do
post_id: post.id, post_id: post.id,
assigned_to_id: group.id, assigned_to_id: group.id,
assigned_to_type: "Group", assigned_to_type: "Group",
assignment_id: assignment.id,
}, },
) )
}.to change { Notification.count }.by(-2) }.to change { Notification.count }.by(-2)

View File

@ -0,0 +1,140 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAssign::CreateNotification do
describe ".call" do
subject(:create_notification) do
described_class.call(assignment: assignment, user: user, mark_as_read: mark_as_read)
end
let(:assignment) { Fabricate(:topic_assignment, topic: post.topic, assigned_to: assigned_to) }
let(:post) { Fabricate(:post) }
let(:mark_as_read) { false }
let(:alerter) { stub_everything("alerter").responds_like_instance_of(PostAlerter) }
before { PostAlerter.stubs(:new).returns(alerter) }
context "when assigned to a single user" do
let(:assigned_to) { Fabricate(:user) }
let(:user) { assigned_to }
it "publishes topic tracking state" do
Assigner.expects(:publish_topic_tracking_state).with(assignment.topic, user.id)
create_notification
end
context "when `mark_as_read` is false" do
let(:excerpt) do
I18n.t(
"discourse_assign.topic_assigned_excerpt",
title: post.topic.title,
group: user.name,
locale: user.effective_locale,
)
end
it "creates a notification alert" do
alerter.expects(:create_notification_alert).with(
user: user,
post: post,
username: assignment.assigned_by_user.username,
notification_type: Notification.types[:assigned],
excerpt: excerpt,
)
create_notification
end
end
context "when `mark_as_read` is true" do
let(:mark_as_read) { true }
it "does not create a notification alert" do
alerter.expects(:create_notification_alert).never
create_notification
end
end
it "creates a notification" do
expect { create_notification }.to change { Notification.count }.by(1)
expect(Notification.assigned.last).to have_attributes(
created_at: assignment.created_at,
updated_at: assignment.updated_at,
user: user,
topic: post.topic,
post_number: post.post_number,
high_priority: true,
read: mark_as_read,
data_hash: {
message: "discourse_assign.assign_notification",
display_username: assignment.assigned_by_user.username,
topic_title: post.topic.title,
assignment_id: assignment.id,
},
)
end
end
context "when assigned to a group" do
let(:assigned_to) { Fabricate(:group) }
let(:user) { Fabricate(:user) }
before { assigned_to.users << user }
it "publishes topic tracking state" do
Assigner.expects(:publish_topic_tracking_state).with(assignment.topic, user.id)
create_notification
end
context "when `mark_as_read` is false" do
let(:excerpt) do
I18n.t(
"discourse_assign.topic_group_assigned_excerpt",
title: post.topic.title,
group: assigned_to.name,
locale: user.effective_locale,
)
end
it "creates a notification alert" do
alerter.expects(:create_notification_alert).with(
user: user,
post: post,
username: assignment.assigned_by_user.username,
notification_type: Notification.types[:assigned],
excerpt: excerpt,
)
create_notification
end
end
context "when `mark_as_read` is true" do
let(:mark_as_read) { true }
it "does not create a notification alert" do
alerter.expects(:create_notification_alert).never
create_notification
end
end
it "creates a notification" do
expect { create_notification }.to change { Notification.count }.by(1)
expect(Notification.assigned.last).to have_attributes(
created_at: assignment.created_at,
updated_at: assignment.updated_at,
user: user,
topic: post.topic,
post_number: post.post_number,
high_priority: true,
read: mark_as_read,
data_hash: {
message: "discourse_assign.assign_group_notification",
display_username: assigned_to.name,
topic_title: post.topic.title,
assignment_id: assignment.id,
},
)
end
end
end
end

View File

@ -2,26 +2,190 @@
require "rails_helper" require "rails_helper"
describe Assignment do RSpec.describe Assignment do
fab!(:group) { Fabricate(:group) }
fab!(:user1) { Fabricate(:user) }
fab!(:user2) { Fabricate(:user) }
fab!(:group_user1) { Fabricate(:group_user, user: user1, group: group) }
fab!(:group_user1) { Fabricate(:group_user, user: user2, group: group) }
fab!(:wrong_group) { Fabricate(:group) }
before { SiteSetting.assign_enabled = true } before { SiteSetting.assign_enabled = true }
describe "#active_for_group" do describe ".active_for_group" do
it "returns active assignments for the group" do subject(:assignments) { described_class.active_for_group(group) }
assignment1 = Fabricate(:topic_assignment, assigned_to: group)
assignment2 = Fabricate(:post_assignment, assigned_to: group) let!(:group) { Fabricate(:group) }
let!(:user1) { Fabricate(:user) }
let!(:user2) { Fabricate(:user) }
let!(:group_user1) { Fabricate(:group_user, user: user1, group: group) }
let!(:group_user2) { Fabricate(:group_user, user: user2, group: group) }
let!(:wrong_group) { Fabricate(:group) }
let!(:assignment1) { Fabricate(:topic_assignment, assigned_to: group) }
let!(:assignment2) { Fabricate(:post_assignment, assigned_to: group) }
before do
Fabricate(:post_assignment, assigned_to: group, active: false) Fabricate(:post_assignment, assigned_to: group, active: false)
Fabricate(:post_assignment, assigned_to: user1) Fabricate(:post_assignment, assigned_to: user1)
Fabricate(:topic_assignment, assigned_to: wrong_group) Fabricate(:topic_assignment, assigned_to: wrong_group)
end
expect(Assignment.active_for_group(group)).to contain_exactly(assignment1, assignment2) it "returns active assignments for the group" do
expect(assignments).to contain_exactly(assignment1, assignment2)
end
end
describe "#assigned_users" do
subject(:assigned_users) { assignment.assigned_users }
let(:assignment) { Fabricate.build(:topic_assignment, assigned_to: assigned_to) }
context "when assigned to a group" do
let(:assigned_to) { Fabricate.build(:group) }
context "when group is empty" do
it "returns an empty collection" do
expect(assigned_users).to be_empty
end
end
context "when group is not empty" do
before { assigned_to.users = Fabricate.build_times(2, :user) }
it "returns users from that group" do
expect(assigned_users).to eq(assigned_to.users)
end
end
end
context "when assigned to a user" do
let(:assigned_to) { Fabricate.build(:user) }
it "returns that user" do
expect(assigned_users).to eq([assigned_to])
end
end
end
describe "#post" do
subject(:post) { assignment.post }
context "when target is a topic" do
let!(:initial_post) { Fabricate(:post) }
let(:assignment) { Fabricate.build(:topic_assignment, topic: target) }
let(:target) { initial_post.topic }
it "returns the first post of that topic" do
expect(post).to eq(initial_post)
end
end
context "when target is a post" do
let(:assignment) { Fabricate.build(:post_assignment) }
it "returns that post" do
expect(post).to eq(assignment.target)
end
end
end
describe "#create_missing_notifications!" do
subject(:create_missing_notifications) do
assignment.create_missing_notifications!(mark_as_read: mark_as_read)
end
let(:assignment) do
Fabricate(:topic_assignment, assigned_to: assigned_to, assigned_by_user: assigned_by_user)
end
let(:mark_as_read) { false }
let(:assigned_by_user) { Fabricate(:user) }
context "when assigned to a user" do
let(:assigned_to) { Fabricate(:user) }
context "when notification already exists for that user" do
before do
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: assigned_to,
data: { assignment_id: assignment.id }.to_json,
)
end
it "does nothing" do
DiscourseAssign::CreateNotification.expects(:call).never
create_missing_notifications
end
end
context "when notification does not exist yet" do
context "when `mark_as_read` is true" do
let(:mark_as_read) { true }
it "creates the missing notification" do
DiscourseAssign::CreateNotification.expects(:call).with(
assignment: assignment,
user: assigned_to,
mark_as_read: true,
)
create_missing_notifications
end
end
context "when `mark_as_read` is false" do
context "when user is the one that assigned" do
let(:assigned_by_user) { assigned_to }
it "creates the missing notification" do
DiscourseAssign::CreateNotification.expects(:call).with(
assignment: assignment,
user: assigned_to,
mark_as_read: true,
)
create_missing_notifications
end
end
context "when user is not the one that assigned" do
it "creates the missing notification" do
DiscourseAssign::CreateNotification.expects(:call).with(
assignment: assignment,
user: assigned_to,
mark_as_read: false,
)
create_missing_notifications
end
end
end
end
end
context "when assigned to a group" do
let(:assigned_to) { Fabricate(:group) }
let(:users) { Fabricate.times(3, :user) }
let(:assigned_by_user) { users.last }
before do
assigned_to.users = users
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: users.first,
data: { assignment_id: assignment.id }.to_json,
)
end
it "creates missing notifications for group users" do
DiscourseAssign::CreateNotification
.expects(:call)
.with(assignment: assignment, user: users.first, mark_as_read: false)
.never
DiscourseAssign::CreateNotification.expects(:call).with(
assignment: assignment,
user: users.second,
mark_as_read: false,
)
DiscourseAssign::CreateNotification.expects(:call).with(
assignment: assignment,
user: users.last,
mark_as_read: true,
)
create_missing_notifications
end
end end
end end
end end

View File

@ -2,38 +2,57 @@
require "rails_helper" require "rails_helper"
describe DiscourseAssign do RSpec.describe DiscourseAssign do
before { SiteSetting.assign_enabled = true } before { SiteSetting.assign_enabled = true }
describe "events" do describe "Events" do
describe "on user_removed_from_group" do describe "on 'user_removed_from_group'" do
let(:group) { Fabricate(:group) }
let(:user) { Fabricate(:user) }
let(:first_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
let(:second_assignment) { Fabricate(:post_assignment, assigned_to: group) }
before do before do
@topic = Fabricate(:post).topic group.users << user
@user = Fabricate(:user) Fabricate(
@group_a = Fabricate(:group) :notification,
@group_a.add(@user) notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: first_assignment.id }.to_json,
)
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: second_assignment.id }.to_json,
)
end end
it "unassigns the user" do it "removes user's notifications related to group assignments" do
SiteSetting.assign_allowed_on_groups = @group_a.id.to_s expect { group.remove(user) }.to change { user.notifications.assigned.count }.by(-2)
Assigner.new(@topic, Discourse.system_user).assign(@user)
@group_a.remove(@user)
expect(Assignment.count).to eq(0)
end end
end
it "doesn't unassign the user if it still has access through another group" do describe "on 'user_added_to_group'" do
@group_b = Fabricate(:group) let(:group) { Fabricate(:group) }
@group_b.add(@user) let(:user) { Fabricate(:user) }
SiteSetting.assign_allowed_on_groups = [@group_a.id.to_s, @group_b.id.to_s].join("|") let!(:first_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
let!(:second_assignment) { Fabricate(:post_assignment, assigned_to: group) }
let!(:third_assignment) { Fabricate(:topic_assignment, assigned_to: group, active: false) }
Assigner.new(@topic, Discourse.system_user).assign(@user) it "creates missing notifications for added user" do
@group_a.remove(@user) group.add(user)
[first_assignment, second_assignment].each do |assignment|
assignment = Assignment.first expect_job_enqueued(job: Jobs::AssignNotification, args: { assignment_id: assignment.id })
expect(assignment.assigned_to_id).to eq(@user.id) end
expect(assignment.assigned_by_user_id).to eq(Discourse::SYSTEM_USER_ID) expect(
job_enqueued?(
job: Jobs::AssignNotification,
args: {
assignment_id: third_assignment.id,
},
),
).to eq(false)
end end
end end
end end

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
require "rails_helper"
require Rails.root.join(
"plugins/discourse-assign/db/post_migrate/20231011152903_ensure_notifications_consistency",
)
# As this post migration is calling app code, we want to ensure its behavior
# wont change over time.
RSpec.describe EnsureNotificationsConsistency do
describe "#up" do
subject(:migrate) { described_class.new.up }
context "when notification targeting a non-existing assignment exists" do
let(:post) { Fabricate(:post) }
let!(:notifications) do
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
post: post,
data: { assignment_id: 1 }.to_json,
)
end
it "deletes it" do
expect { migrate }.to change { Notification.count }.by(-1)
end
end
context "when notification targeting an inactive assignment exists" do
let(:post) { Fabricate(:post) }
let(:assignment) { Fabricate(:topic_assignment, topic: post.topic, active: false) }
let!(:notifications) do
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
post: post,
data: { assignment_id: assignment.id }.to_json,
)
end
it "deletes it" do
expect { migrate }.to change { Notification.count }.by(-1)
end
end
context "when some active assignments exist" do
let(:post) { Fabricate(:post) }
let(:group) { Fabricate(:group) }
let!(:assignment) { Fabricate(:topic_assignment, topic: post.topic, assigned_to: group) }
let!(:inactive_assignment) { Fabricate(:post_assignment, post: post, active: false) }
let!(:assignment_with_deleted_topic) { Fabricate(:topic_assignment) }
before do
group.users << Fabricate(:user)
assignment_with_deleted_topic.topic.trash!
end
context "when notifications are missing" do
it "creates them" do
expect { migrate }.to change { Notification.assigned.count }.by(1)
end
end
end
end
end

View File

@ -379,7 +379,7 @@ RSpec.describe DiscourseAssign::AssignController do
it "doesn't include members with no assignments" do it "doesn't include members with no assignments" do
sign_in(admin) sign_in(admin)
allowed_group.add(non_admin_staff) allowed_group.users << non_admin_staff
get "/assign/members/#{allowed_group.name}.json" get "/assign/members/#{allowed_group.name}.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -458,138 +458,4 @@ RSpec.describe DiscourseAssign::AssignController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
end end
end end
describe "#user_menu_assigns" do
fab!(:non_member_admin) { Fabricate(:admin) }
fab!(:unread_assigned_topic) { Fabricate(:post).topic }
fab!(:read_assigned_topic) { Fabricate(:post).topic }
fab!(:unread_assigned_post) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:read_assigned_post) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:read_assigned_post_in_same_topic) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:unread_assigned_post_in_same_topic) do
Fabricate(:post, topic: read_assigned_post_in_same_topic.topic)
end
fab!(:another_user_unread_assigned_topic) { Fabricate(:post).topic }
fab!(:another_user_read_assigned_topic) { Fabricate(:post).topic }
before do
Jobs.run_immediately!
[
unread_assigned_topic,
read_assigned_topic,
unread_assigned_post,
read_assigned_post,
unread_assigned_post_in_same_topic,
read_assigned_post_in_same_topic,
].each { |target| Assigner.new(target, non_member_admin).assign(admin) }
Notification
.where(
notification_type: Notification.types[:assigned],
read: false,
user_id: admin.id,
topic_id: [
read_assigned_topic.id,
read_assigned_post.topic.id,
read_assigned_post_in_same_topic.topic.id,
],
)
.where.not(
topic_id: read_assigned_post_in_same_topic.topic.id,
post_number: unread_assigned_post_in_same_topic.post_number,
)
.update_all(read: true)
Assigner.new(another_user_read_assigned_topic, non_member_admin).assign(allowed_user)
Assigner.new(another_user_unread_assigned_topic, non_member_admin).assign(allowed_user)
Notification.where(
notification_type: Notification.types[:assigned],
read: false,
user_id: allowed_user.id,
topic_id: another_user_read_assigned_topic,
).update_all(read: true)
end
context "when logged out" do
it "responds with 403" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(403)
end
end
context "when logged in" do
before { sign_in(admin) }
it "responds with 403 if the current user can't assign" do
admin.update!(admin: false)
admin.group_users.where(group_id: staff_group.id).destroy_all
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(403)
end
it "responds with 404 if the assign_enabled setting is disabled" do
SiteSetting.assign_enabled = false
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(404)
end
it "sends an array of unread assigned notifications" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.map { |n| [n["topic_id"], n["post_number"]] }).to match_array(
[
[unread_assigned_topic.id, 1],
[unread_assigned_post.topic.id, unread_assigned_post.post_number],
[
unread_assigned_post_in_same_topic.topic.id,
unread_assigned_post_in_same_topic.post_number,
],
],
)
end
it "responds with an array of assigned topics that are not associated with any of the unread assigned notifications" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(200)
topics = response.parsed_body["topics"]
expect(topics.map { |t| t["id"] }).to eq(
[
read_assigned_post_in_same_topic.topic.id,
read_assigned_post.topic.id,
read_assigned_topic.id,
],
)
end
it "fills up the remaining of the UsersController::USER_MENU_LIST_LIMIT limit with assigned topics" do
stub_const(UsersController, "USER_MENU_LIST_LIMIT", 3) do
get "/assign/user-menu-assigns.json"
end
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.size).to eq(3)
topics = response.parsed_body["topics"]
expect(topics.size).to eq(0)
stub_const(UsersController, "USER_MENU_LIST_LIMIT", 4) do
get "/assign/user-menu-assigns.json"
end
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.size).to eq(3)
topics = response.parsed_body["topics"]
expect(topics.size).to eq(1)
end
end
end
end end

View File

@ -1,6 +1,5 @@
import { click, currentURL, visit } from "@ember/test-helpers"; import { click, currentURL, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
import { import {
acceptance, acceptance,
query, query,
@ -20,179 +19,32 @@ const USER_MENU_ASSIGN_RESPONSE = {
created_at: "2022-08-11T21:32:32.404Z", created_at: "2022-08-11T21:32:32.404Z",
post_number: 1, post_number: 1,
topic_id: 227, topic_id: 227,
fancy_title: "Test poll topic please bear with me", fancy_title: "Test poll topic please bear with me :heart:",
slug: "test-poll-topic-please-bear-with-me", slug: "test-poll-topic-please-bear-with-me",
data: { data: {
message: "discourse_assign.assign_notification", message: "discourse_assign.assign_notification",
display_username: "tony", display_username: "tony",
topic_title: "Test poll topic please bear with me", topic_title: "Test poll topic please bear with me :heart:",
assignment_id: 2, assignment_id: 2,
}, },
}, },
],
topics: [
{ {
id: 209, id: 1717,
title: "Howdy this a test topic!", user_id: 1,
fancy_title: "Howdy this <b>my test topic</b> with emoji :heart:!", notification_type: 34,
slug: "howdy-this-a-test-topic", read: true,
posts_count: 1, high_priority: true,
reply_count: 0, created_at: "2022-08-11T21:32:32.404Z",
highest_post_number: 1, post_number: 1,
image_url: null, topic_id: 228,
created_at: "2022-03-10T20:09:25.772Z", fancy_title: "Test poll topic please bear with me 2 :ok_hand:",
last_posted_at: "2022-03-10T20:09:25.959Z", slug: "test-poll-topic-please-bear-with-me-2",
bumped: true, data: {
bumped_at: "2022-03-10T20:09:25.959Z", message: "discourse_assign.assign_group_notification",
archetype: "regular", display_username: "Team",
unseen: false, topic_title: "Test poll topic please bear with me 2 :ok_hand:",
last_read_post_number: 2, assignment_id: 3,
unread: 0,
new_posts: 0,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 3,
bookmarked: false,
liked: false,
thumbnails: null,
tags: [],
tags_descriptions: {},
views: 11,
like_count: 7,
has_summary: false,
last_poster_username: "osama",
category_id: 1,
pinned_globally: false,
featured_link: null,
assigned_to_user: {
id: 1,
username: "osama",
name: "Osama.OG",
avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
assign_icon: "user-plus",
assign_path: "/u/osama/activity/assigned",
}, },
posters: [
{
extras: "latest single",
description: "Original Poster, Most Recent Poster",
user_id: 1,
primary_group_id: 45,
flair_group_id: 45,
},
],
},
{
id: 173,
title: "Owners elegance entrance startled spirits losing",
fancy_title:
"Owners <i>elegance entrance :car: startled</i> spirits losing",
slug: "owners-elegance-entrance-startled-spirits-losing",
posts_count: 7,
reply_count: 0,
highest_post_number: 7,
image_url: null,
created_at: "2021-07-11T04:50:17.029Z",
last_posted_at: "2021-12-24T17:21:03.418Z",
bumped: true,
bumped_at: "2021-12-24T17:21:03.418Z",
archetype: "regular",
unseen: false,
last_read_post_number: 3,
unread: 0,
new_posts: 0,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 1,
bookmarked: false,
liked: false,
thumbnails: null,
tags: ["music", "job-application"],
tags_descriptions: {},
views: 23,
like_count: 24,
has_summary: false,
last_poster_username: "ambrose.bradtke",
category_id: 1,
pinned_globally: false,
featured_link: null,
assigned_to_group: {
id: 45,
automatic: false,
name: "Team",
user_count: 4,
mentionable_level: 99,
messageable_level: 99,
visibility_level: 0,
primary_group: true,
title: "",
grant_trust_level: null,
incoming_email: null,
has_messages: true,
flair_url: null,
flair_bg_color: "",
flair_color: "",
bio_raw: "",
bio_cooked: null,
bio_excerpt: null,
public_admission: true,
public_exit: true,
allow_membership_requests: false,
full_name: "",
default_notification_level: 3,
membership_request_template: "",
members_visibility_level: 0,
can_see_members: true,
can_admin_group: true,
publish_read_state: true,
assign_icon: "group-plus",
assign_path: "/g/Team/assigned/everyone",
},
posters: [
{
extras: null,
description: "Original Poster",
user_id: 26,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 16,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 22,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 12,
primary_group_id: null,
flair_group_id: null,
},
{
extras: "latest",
description: "Most Recent Poster",
user_id: 13,
primary_group_id: null,
flair_group_id: null,
},
],
}, },
], ],
}; };
@ -250,9 +102,9 @@ acceptance("Discourse Assign | user menu", function (needs) {
let requestBody; let requestBody;
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
server.get("/assign/user-menu-assigns.json", () => { server.get("/notifications", () => {
if (forceEmptyState) { if (forceEmptyState) {
return helper.response({ notifications: [], topics: [] }); return helper.response({ notifications: [] });
} else { } else {
return helper.response(USER_MENU_ASSIGN_RESPONSE); return helper.response(USER_MENU_ASSIGN_RESPONSE);
} }
@ -321,7 +173,9 @@ acceptance("Discourse Assign | user menu", function (needs) {
await click(".d-header-icons .current-user"); await click(".d-header-icons .current-user");
await click("#user-menu-button-assign-list"); await click("#user-menu-button-assign-list");
const notifications = queryAll("#quick-access-assign-list .notification"); const notifications = queryAll(
"#quick-access-assign-list .notification.unread"
);
assert.strictEqual( assert.strictEqual(
notifications.length, notifications.length,
1, 1,
@ -336,7 +190,7 @@ acceptance("Discourse Assign | user menu", function (needs) {
"the notification is of type assigned" "the notification is of type assigned"
); );
const assigns = queryAll("#quick-access-assign-list .assign"); const assigns = queryAll("#quick-access-assign-list .assigned");
assert.strictEqual(assigns.length, 2, "there are 2 assigns"); assert.strictEqual(assigns.length, 2, "there are 2 assigns");
const userAssign = assigns[0]; const userAssign = assigns[0];
@ -353,59 +207,44 @@ acceptance("Discourse Assign | user menu", function (needs) {
assert.true( assert.true(
userAssign userAssign
.querySelector("a") .querySelector("a")
.href.endsWith("/t/howdy-this-a-test-topic/209/3"), .href.endsWith("/t/test-poll-topic-please-bear-with-me/227"),
"user assign links to the first unread post (last read post + 1)" "user assign links to the assigned topic"
); );
assert.true( assert.true(
groupAssign groupAssign
.querySelector("a") .querySelector("a")
.href.endsWith( .href.endsWith("/t/test-poll-topic-please-bear-with-me-2/228"),
"/t/owners-elegance-entrance-startled-spirits-losing/173/4" "group assign links to the assigned topic"
),
"group assign links to the first unread post (last read post + 1)"
); );
assert.strictEqual( assert.strictEqual(
userAssign.textContent.trim(), userAssign.textContent.trim(),
"Howdy this my test topic with emoji !", "Test poll topic please bear with me",
"user assign contains the topic title" "user assign contains the topic title"
); );
assert.ok( assert.ok(
userAssign.querySelector(".item-description img.emoji"), userAssign.querySelector(".item-description img.emoji"),
"emojis are rendered in user assign" "emojis are rendered in user assign"
); );
assert.strictEqual(
userAssign.querySelector(".item-description b").textContent.trim(),
"my test topic",
"user assign topic title is trusted"
);
assert.strictEqual( assert.strictEqual(
groupAssign.textContent.trim().replaceAll(/\s+/g, " "), groupAssign.textContent.trim().replaceAll(/\s+/g, " "),
"Owners elegance entrance startled spirits losing", "Team Test poll topic please bear with me 2",
"group assign contains the topic title" "group assign contains the topic title"
); );
assert.ok( assert.ok(
groupAssign.querySelector(".item-description i img.emoji"), groupAssign.querySelector(".item-description img.emoji"),
"emojis are rendered in group assign" "emojis are rendered in group assign"
); );
assert.strictEqual(
groupAssign
.querySelector(".item-description i")
.textContent.trim()
.replaceAll(/\s+/g, " "),
"elegance entrance startled",
"group assign topic title is trusted"
);
assert.strictEqual( assert.strictEqual(
userAssign.querySelector("a").title, userAssign.querySelector("a").title,
I18n.t("user.assigned_to_you"), I18n.t("user.assigned_to_you.topic"),
"user assign has the right title" "user assign has the right title"
); );
assert.strictEqual( assert.strictEqual(
groupAssign.querySelector("a").title, groupAssign.querySelector("a").title,
I18n.t("user.assigned_to_group", { group_name: "Team" }), I18n.t("user.assigned_to_group.topic", { group_name: "Team" }),
"group assign has the right title" "group assign has the right title"
); );
}); });
@ -466,37 +305,6 @@ acceptance("Discourse Assign | user menu", function (needs) {
); );
}); });
test("assigns tab applies model transformations", async function (assert) {
withPluginApi("0.1", (api) => {
api.registerModelTransformer("notification", (notifications) => {
notifications.forEach((notification) => {
notification.fancy_title = `notificationModelTransformer ${notification.fancy_title}`;
});
});
api.registerModelTransformer("topic", (topics) => {
topics.forEach((topic) => {
topic.fancy_title = `topicModelTransformer ${topic.fancy_title}`;
});
});
});
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-assign-list");
const notification = query("#quick-access-assign-list ul li.notification");
assert.strictEqual(
notification.textContent.replace(/\s+/g, " ").trim(),
"tony notificationModelTransformer Test poll topic please bear with me"
);
const assign = query("#quick-access-assign-list ul li.assign");
assert.strictEqual(
assign.textContent.replace(/\s+/g, " ").trim(),
"topicModelTransformer Howdy this my test topic with emoji !"
);
});
test("renders the confirmation modal when dismiss assign notifications", async function (assert) { test("renders the confirmation modal when dismiss assign notifications", async function (assert) {
await visit("/"); await visit("/");
await click(".d-header-icons .current-user"); await click(".d-header-icons .current-user");