586 lines
18 KiB
Ruby
586 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "email/sender"
|
|
require "nokogiri"
|
|
|
|
class ::Assigner
|
|
ASSIGNMENTS_PER_TOPIC_LIMIT = 5
|
|
|
|
def self.backfill_auto_assign
|
|
staff_mention =
|
|
User
|
|
.assign_allowed
|
|
.pluck("username")
|
|
.map { |name| "p.cooked ILIKE '%mention%@#{name}%'" }
|
|
.join(" OR ")
|
|
|
|
sql = <<~SQL
|
|
SELECT p.topic_id, MAX(post_number) post_number
|
|
FROM posts p
|
|
JOIN topics t ON t.id = p.topic_id
|
|
LEFT JOIN assignments a ON a.target_id = p.topic_id AND a.target_type = 'Topic'
|
|
WHERE p.user_id IN (SELECT id FROM users WHERE moderator OR admin)
|
|
AND (#{staff_mention})
|
|
AND a.assigned_to_id IS NULL
|
|
AND NOT t.closed
|
|
AND t.deleted_at IS NULL
|
|
GROUP BY p.topic_id
|
|
SQL
|
|
|
|
puts
|
|
assigned = 0
|
|
|
|
ActiveRecord::Base
|
|
.connection
|
|
.raw_connection
|
|
.exec(sql)
|
|
.to_a
|
|
.each do |row|
|
|
post = Post.find_by(post_number: row["post_number"].to_i, topic_id: row["topic_id"].to_i)
|
|
assigned += 1 if post && auto_assign(post)
|
|
putc "."
|
|
end
|
|
|
|
puts
|
|
puts "#{assigned} topics where automatically assigned to staff members"
|
|
end
|
|
|
|
def self.assigned_self?(text)
|
|
return false if text.blank? || SiteSetting.assign_self_regex.blank?
|
|
regex =
|
|
begin
|
|
Regexp.new(SiteSetting.assign_self_regex)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
!!(regex && text[regex])
|
|
end
|
|
|
|
def self.assigned_other?(text)
|
|
return false if text.blank? || SiteSetting.assign_other_regex.blank?
|
|
regex =
|
|
begin
|
|
Regexp.new(SiteSetting.assign_other_regex)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
!!(regex && text[regex])
|
|
end
|
|
|
|
def self.auto_assign(post, force: false)
|
|
return unless SiteSetting.assigns_by_staff_mention
|
|
|
|
if post.user && post.topic && post.user.can_assign?
|
|
return if post.topic.assignment.present? && !force
|
|
|
|
# remove quotes, oneboxes and code blocks
|
|
doc = Nokogiri::HTML5.fragment(post.cooked)
|
|
doc.css(".quote, .onebox, pre, code").remove
|
|
text = doc.text.strip
|
|
|
|
assign_other = assigned_other?(text) && mentioned_staff(post)
|
|
assign_self = assigned_self?(text) && post.user
|
|
return unless assign_other || assign_self
|
|
|
|
if is_last_staff_post?(post)
|
|
assigner = new(post.topic, post.user)
|
|
if assign_other
|
|
assigner.assign(assign_other, skip_small_action_post: true)
|
|
elsif assign_self
|
|
assigner.assign(assign_self, skip_small_action_post: true)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.is_last_staff_post?(post)
|
|
allowed_user_ids = User.assign_allowed.pluck(:id).join(",")
|
|
|
|
sql = <<~SQL
|
|
SELECT 1
|
|
FROM posts p
|
|
JOIN users u ON u.id = p.user_id
|
|
WHERE p.deleted_at IS NULL
|
|
AND p.topic_id = :topic_id
|
|
AND u.id IN (#{allowed_user_ids})
|
|
HAVING MAX(post_number) = :post_number
|
|
SQL
|
|
|
|
args = { topic_id: post.topic_id, post_number: post.post_number }
|
|
|
|
DB.exec(sql, args) == 1
|
|
end
|
|
|
|
def self.mentioned_staff(post)
|
|
mentions = post.raw_mentions
|
|
if mentions.present?
|
|
User.human_users.assign_allowed.where("username_lower IN (?)", mentions.map(&:downcase)).first
|
|
end
|
|
end
|
|
|
|
def self.publish_topic_tracking_state(topic, user_id)
|
|
if topic.private_message?
|
|
MessageBus.publish("/private-messages/assigned", { topic_id: topic.id }, user_ids: [user_id])
|
|
end
|
|
end
|
|
|
|
def initialize(target, user)
|
|
@assigned_by = user
|
|
@target = target
|
|
end
|
|
|
|
def allowed_user_ids
|
|
@allowed_user_ids ||= User.assign_allowed.pluck(:id)
|
|
end
|
|
|
|
def allowed_group_ids
|
|
@allowed_group_ids ||= Group.assignable(@assigned_by).pluck(:id)
|
|
end
|
|
|
|
def can_assign_to?(assign_to)
|
|
return true if assign_to.is_a?(Group)
|
|
return true if @assigned_by.id == assign_to.id
|
|
|
|
assigned_total =
|
|
Assignment
|
|
.joins_with_topics
|
|
.where(topics: { deleted_at: nil })
|
|
.where(assigned_to_id: assign_to.id, active: true)
|
|
.count
|
|
|
|
assigned_total < SiteSetting.max_assigned_topics
|
|
end
|
|
|
|
def can_be_assigned?(assign_to)
|
|
if assign_to.is_a?(User)
|
|
allowed_user_ids.include?(assign_to.id)
|
|
else
|
|
allowed_group_ids.include?(assign_to.id)
|
|
end
|
|
end
|
|
|
|
def topic_target?
|
|
@topic_target ||= @target.is_a?(Topic)
|
|
end
|
|
|
|
def post_target?
|
|
@post_target ||= @target.is_a?(Post)
|
|
end
|
|
|
|
def private_message_allowed_user_ids
|
|
@private_message_allowed_user_ids ||= topic.all_allowed_users.pluck(:id)
|
|
end
|
|
|
|
def can_assignee_see_target?(assignee)
|
|
if (topic_target? || post_target?) && topic.private_message? &&
|
|
!private_message_allowed_user_ids.include?(assignee.id)
|
|
return false
|
|
end
|
|
return Guardian.new(assignee).can_see_topic?(@target) if topic_target?
|
|
return Guardian.new(assignee).can_see_post?(@target) if post_target?
|
|
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
|
|
def topic
|
|
return @topic if @topic
|
|
@topic = @target if topic_target?
|
|
@topic = @target.topic if post_target?
|
|
|
|
raise Discourse::InvalidParameters if !@topic
|
|
@topic
|
|
end
|
|
|
|
def first_post
|
|
topic.posts.where(post_number: 1).first
|
|
end
|
|
|
|
def forbidden_reasons(assign_to:, type:, note:, status:)
|
|
case
|
|
when assign_to.is_a?(User) && !can_assignee_see_target?(assign_to)
|
|
if topic.private_message?
|
|
:forbidden_assignee_not_pm_participant
|
|
else
|
|
:forbidden_assignee_cant_see_topic
|
|
end
|
|
when assign_to.is_a?(Group) && assign_to.users.any? { |user| !can_assignee_see_target?(user) }
|
|
if topic.private_message?
|
|
:forbidden_group_assignee_not_pm_participant
|
|
else
|
|
:forbidden_group_assignee_cant_see_topic
|
|
end
|
|
when !can_be_assigned?(assign_to)
|
|
assign_to.is_a?(User) ? :forbidden_assign_to : :forbidden_group_assign_to
|
|
when already_assigned?(assign_to, type, note, status)
|
|
assign_to.is_a?(User) ? :already_assigned : :group_already_assigned
|
|
when Assignment.where(topic: topic, active: true).count >= ASSIGNMENTS_PER_TOPIC_LIMIT &&
|
|
!reassign?
|
|
:too_many_assigns_for_topic
|
|
when !can_assign_to?(assign_to)
|
|
:too_many_assigns
|
|
end
|
|
end
|
|
|
|
def update_details(assign_to, note, status, skip_small_action_post: false)
|
|
case
|
|
when @target.assignment.note != note && @target.assignment.status != status && status.present?
|
|
small_action_text = <<~TEXT
|
|
Status: #{@target.assignment.status} → #{status}
|
|
|
|
#{note}
|
|
TEXT
|
|
change_type = "details"
|
|
when @target.assignment.note != note
|
|
small_action_text = note
|
|
change_type = "note"
|
|
when @target.assignment.status != status
|
|
small_action_text = "#{@target.assignment.status} → #{status}"
|
|
change_type = "status"
|
|
end
|
|
|
|
@target.assignment.update!(note: note, status: status)
|
|
|
|
queue_notification(assign_to, skip_small_action_post, @target.assignment)
|
|
|
|
assignment = @target.assignment
|
|
publish_assignment(assignment, assign_to, note, status)
|
|
|
|
# email is skipped, for now
|
|
|
|
unless skip_small_action_post
|
|
action_code = "#{change_type}_change"
|
|
add_small_action_post(action_code, assign_to, small_action_text)
|
|
end
|
|
|
|
{ success: true }
|
|
end
|
|
|
|
def assign(assign_to, note: nil, skip_small_action_post: false, status: nil)
|
|
assigned_to_type = assign_to.is_a?(User) ? "User" : "Group"
|
|
|
|
if topic.private_message? && SiteSetting.invite_on_assign
|
|
assigned_to_type == "Group" ? invite_group(assign_to) : invite_user(assign_to)
|
|
end
|
|
|
|
forbidden_reason =
|
|
forbidden_reasons(assign_to: assign_to, type: assigned_to_type, note: note, status: status)
|
|
return { success: false, reason: forbidden_reason } if forbidden_reason
|
|
|
|
if no_assignee_change?(assign_to) && details_change?(note, status)
|
|
return update_details(assign_to, note, status, skip_small_action_post: skip_small_action_post)
|
|
end
|
|
|
|
action_code = {}
|
|
action_code[:user] = topic.assignment.present? ? "reassigned" : "assigned"
|
|
action_code[:group] = topic.assignment.present? ? "reassigned_group" : "assigned_group"
|
|
|
|
skip_small_action_post = skip_small_action_post || no_assignee_change?(assign_to)
|
|
|
|
if topic.assignment.present?
|
|
Jobs.enqueue(
|
|
:unassign_notification,
|
|
topic_id: topic.id,
|
|
assigned_to_id: topic.assignment.assigned_to_id,
|
|
assigned_to_type: topic.assignment.assigned_to_type,
|
|
)
|
|
end
|
|
|
|
@target.assignment&.destroy!
|
|
|
|
assignment =
|
|
@target.create_assignment!(
|
|
assigned_to_id: assign_to.id,
|
|
assigned_to_type: assigned_to_type,
|
|
assigned_by_user_id: @assigned_by.id,
|
|
topic_id: topic.id,
|
|
note: note,
|
|
status: status,
|
|
)
|
|
|
|
first_post.publish_change_to_clients!(:revised, reload_topic: true)
|
|
|
|
queue_notification(assign_to, skip_small_action_post, assignment)
|
|
|
|
publish_assignment(assignment, assign_to, note, status)
|
|
|
|
if assignment.assigned_to_user?
|
|
if !TopicUser.exists?(
|
|
user_id: assign_to.id,
|
|
topic_id: topic.id,
|
|
notification_level: TopicUser.notification_levels[:watching],
|
|
)
|
|
TopicUser.change(
|
|
assign_to.id,
|
|
topic.id,
|
|
notification_level: TopicUser.notification_levels[:watching],
|
|
notifications_reason_id: TopicUser.notification_reasons[:plugin_changed],
|
|
)
|
|
end
|
|
|
|
if SiteSetting.assign_mailer == AssignMailer.levels[:always] ||
|
|
(
|
|
SiteSetting.assign_mailer == AssignMailer.levels[:different_users] &&
|
|
@assigned_by.id != assign_to.id
|
|
)
|
|
if !topic.muted?(assign_to)
|
|
message = AssignMailer.send_assignment(assign_to.email, topic, @assigned_by)
|
|
Email::Sender.new(message, :assign_message).send
|
|
end
|
|
end
|
|
end
|
|
|
|
unless skip_small_action_post
|
|
post_action_code = moderator_post_assign_action_code(assignment, action_code)
|
|
add_small_action_post(post_action_code, assign_to, note)
|
|
end
|
|
|
|
# Create a webhook event
|
|
if WebHook.active_web_hooks(:assign).exists?
|
|
assigned_to_type = :assigned
|
|
payload = {
|
|
type: assigned_to_type,
|
|
topic_id: topic.id,
|
|
topic_title: topic.title,
|
|
assigned_by_id: @assigned_by.id,
|
|
assigned_by_username: @assigned_by.username,
|
|
}
|
|
if assignment.assigned_to_user?
|
|
payload.merge!({ assigned_to_id: assign_to.id, assigned_to_username: assign_to.username })
|
|
else
|
|
payload.merge!(
|
|
{ assigned_to_group_id: assign_to.id, assigned_to_group_name: assign_to.name },
|
|
)
|
|
end
|
|
WebHook.enqueue_assign_hooks(assigned_to_type, payload.to_json)
|
|
end
|
|
|
|
{ success: true }
|
|
end
|
|
|
|
def unassign(silent: false, deactivate: false)
|
|
if assignment = @target.assignment
|
|
deactivate ? assignment.update!(active: false) : assignment.destroy!
|
|
|
|
return if first_post.blank?
|
|
|
|
first_post.publish_change_to_clients!(:revised, reload_topic: true)
|
|
|
|
Jobs.enqueue(
|
|
:unassign_notification,
|
|
topic_id: topic.id,
|
|
assigned_to_id: assignment.assigned_to.id,
|
|
assigned_to_type: assignment.assigned_to_type,
|
|
)
|
|
|
|
if assignment.assigned_to_user?
|
|
if TopicUser.exists?(
|
|
user_id: assignment.assigned_to_id,
|
|
topic: topic,
|
|
notification_level: TopicUser.notification_levels[:watching],
|
|
notifications_reason_id: TopicUser.notification_reasons[:plugin_changed],
|
|
)
|
|
TopicUser.change(
|
|
assignment.assigned_to_id,
|
|
topic.id,
|
|
notification_level: TopicUser.notification_levels[:tracking],
|
|
notifications_reason_id: TopicUser.notification_reasons[:plugin_changed],
|
|
)
|
|
end
|
|
end
|
|
|
|
assigned_to = assignment.assigned_to
|
|
|
|
if SiteSetting.unassign_creates_tracking_post && !silent
|
|
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
|
|
|
custom_fields = {
|
|
"action_code_who" => assigned_to.is_a?(User) ? assigned_to.username : assigned_to.name,
|
|
}
|
|
|
|
if post_target?
|
|
custom_fields.merge!("action_code_path" => "/p/#{@target.id}")
|
|
custom_fields.merge!("action_code_post_id" => @target.id)
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
@assigned_by,
|
|
nil,
|
|
bump: false,
|
|
post_type: post_type,
|
|
custom_fields: custom_fields,
|
|
action_code: moderator_post_unassign_action_code(assignment),
|
|
)
|
|
end
|
|
|
|
# Create a webhook event
|
|
if WebHook.active_web_hooks(:assign).exists?
|
|
type = :unassigned
|
|
payload = {
|
|
type: type,
|
|
topic_id: topic.id,
|
|
topic_title: topic.title,
|
|
unassigned_by_id: @assigned_by.id,
|
|
unassigned_by_username: @assigned_by.username,
|
|
}
|
|
if assignment.assigned_to_user?
|
|
payload.merge!(
|
|
{ unassigned_to_id: assigned_to.id, unassigned_to_username: assigned_to.username },
|
|
)
|
|
else
|
|
payload.merge!(
|
|
{ unassigned_to_group_id: assigned_to.id, unassigned_to_group_name: assigned_to.name },
|
|
)
|
|
end
|
|
WebHook.enqueue_assign_hooks(type, payload.to_json)
|
|
end
|
|
|
|
MessageBus.publish(
|
|
"/staff/topic-assignment",
|
|
{
|
|
type: "unassigned",
|
|
topic_id: topic.id,
|
|
post_id: post_target? && @target.id,
|
|
post_number: post_target? && @target.post_number,
|
|
assigned_type: assignment.assigned_to.is_a?(User) ? "User" : "Group",
|
|
assignment_note: nil,
|
|
assignment_status: nil,
|
|
},
|
|
user_ids: allowed_user_ids,
|
|
)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def invite_user(user)
|
|
return if topic.all_allowed_users.exists?(id: user.id)
|
|
|
|
guardian.ensure_can_invite_to!(topic)
|
|
topic.invite(@assigned_by, user.username)
|
|
end
|
|
|
|
def invite_group(group)
|
|
return if topic.topic_allowed_groups.exists?(group_id: group.id)
|
|
if topic
|
|
.all_allowed_users
|
|
.joins("RIGHT JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id = ? AND users.id IS NULL", group.id)
|
|
.empty?
|
|
return # all group members can already see the topic
|
|
end
|
|
|
|
guardian.ensure_can_invite_group_to_private_message!(group, topic)
|
|
topic.invite_group(@assigned_by, group)
|
|
end
|
|
|
|
def guardian
|
|
@guardian ||= Guardian.new(@assigned_by)
|
|
end
|
|
|
|
def queue_notification(assign_to, skip_small_action_post, assignment)
|
|
Jobs.enqueue(
|
|
: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
|
|
|
|
def add_small_action_post(action_code, assign_to, text)
|
|
custom_fields = {
|
|
"action_code_who" => assign_to.is_a?(User) ? assign_to.username : assign_to.name,
|
|
}
|
|
|
|
if post_target?
|
|
custom_fields.merge!(
|
|
{ "action_code_path" => "/p/#{@target.id}", "action_code_post_id" => @target.id },
|
|
)
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
@assigned_by,
|
|
text,
|
|
bump: false,
|
|
post_type: SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper],
|
|
action_code: action_code,
|
|
custom_fields: custom_fields,
|
|
)
|
|
end
|
|
|
|
def publish_assignment(assignment, assign_to, note, status)
|
|
serializer = assignment.assigned_to_user? ? BasicUserSerializer : BasicGroupSerializer
|
|
MessageBus.publish(
|
|
"/staff/topic-assignment",
|
|
{
|
|
type: "assigned",
|
|
topic_id: topic.id,
|
|
post_id: post_target? && @target.id,
|
|
post_number: post_target? && @target.post_number,
|
|
assigned_type: assignment.assigned_to_type,
|
|
assigned_to: serializer.new(assign_to, scope: Guardian.new, root: false).as_json,
|
|
assignment_note: note,
|
|
assignment_status: status,
|
|
},
|
|
user_ids: allowed_user_ids,
|
|
)
|
|
end
|
|
|
|
def moderator_post_assign_action_code(assignment, action_code)
|
|
if assignment.target.is_a?(Post)
|
|
# posts do not have to handle conditions of 'assign' or 'reassign'
|
|
assignment.assigned_to_user? ? "assigned_to_post" : "assigned_group_to_post"
|
|
elsif assignment.target.is_a?(Topic)
|
|
assignment.assigned_to_user? ? "#{action_code[:user]}" : "#{action_code[:group]}"
|
|
end
|
|
end
|
|
|
|
def moderator_post_unassign_action_code(assignment)
|
|
suffix =
|
|
if assignment.target.is_a?(Post)
|
|
"_from_post"
|
|
elsif assignment.target.is_a?(Topic)
|
|
""
|
|
end
|
|
return "unassigned#{suffix}" if assignment.assigned_to_user?
|
|
return "unassigned_group#{suffix}" if assignment.assigned_to_group?
|
|
end
|
|
|
|
def already_assigned?(assign_to, type, note, status)
|
|
return true if assignment_eq?(@target.assignment, assign_to, type, note, status)
|
|
|
|
# Check if the user is not assigned to any of the posts from the topic
|
|
# they will be assigned to.
|
|
if @target.is_a?(Topic)
|
|
assignments = Assignment.where(topic_id: topic.id, target_type: "Post", active: true)
|
|
return true if assignments.any? { |a| assignment_eq?(a, assign_to, type, note, status) }
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def reassign?
|
|
return false if !@target.is_a?(Topic)
|
|
Assignment.exists?(topic_id: @target.id, target: @target, active: true)
|
|
end
|
|
|
|
def no_assignee_change?(assignee)
|
|
@target.assignment&.assigned_to_id == assignee.id
|
|
end
|
|
|
|
def details_change?(note, status)
|
|
note.present? || @target.assignment&.status != status
|
|
end
|
|
|
|
def assignment_eq?(assignment, assign_to, type, note, status)
|
|
return false if !assignment&.active
|
|
return false if assignment.assigned_to_id != assign_to.id
|
|
return false if assignment.assigned_to_type != type
|
|
return false if assignment.note != note
|
|
assignment.status == status || !status && assignment.status == Assignment.default_status
|
|
end
|
|
end
|