2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-03 23:54:20 +08:00
discourse/app/models/notification.rb
Martin Brennan fb9bb31983
FEATURE: Notify admins of upcoming changes and log events (#37003)
This commit adds several pieces of functionality to help keep admins
in the loop about upcoming changes.

First of all, there is a new initializer on boot that will notify admins
about
newly available upcoming changes, as well as log removed changes and
status movement of existing changes.

* When there is a new upcoming change, we only notify admins about
   it when the status is the `promote_upcoming_changes_on_status` - 1,
   e.g. if `promote_upcoming_changes_on_status` is `beta` then we only
   tell admin about the change once it has reached `alpha`. This means
   we may log the `added` event in one deploy, but only actually notify
   admins in a subsequent deploy.
* We log removed upcoming changes so we can automatically delete old
   site setting data in a future job as needed.

We also now  notify  admins when  upcoming changes are automatically
promoted to enabled based on the site's
`promote_upcoming_changes_on_status`:

<img width="378" height="600" alt="image"
src="https://github.com/user-attachments/assets/4200fbee-9990-4bbc-a378-85946e631e77"
/>

In addition, we now show an indicator in the admin sidebar
if there are new upcoming changes that have been added since
they last visited the upcoming change config page. This data
is stored in a user custom field, because Redis is ephemeral,
and storing in the User table is overkill because 99% of users
are not staff:

<img width="248" height="112" alt="image"
src="https://github.com/user-attachments/assets/4c3d3cf7-ac39-45f8-a2c8-a049cb85b8e9"
/>

Finally, this commit moves both the Track and Promote initializer
logic behind a `DistributedMutex`, we don't want multiple processes
running the same logic here, it needs to be only once.

---------

Co-authored-by: Loïc Guitaut <loic@discourse.org>
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2026-01-21 12:45:54 +10:00

467 lines
15 KiB
Ruby

# frozen_string_literal: true
class Notification < ActiveRecord::Base
attr_accessor :acting_user
attr_accessor :acting_username
belongs_to :user
belongs_to :topic
has_one :shelved_notification
MEMBERSHIP_REQUEST_CONSOLIDATION_WINDOW_HOURS = 24
validates :data, presence: true
validates :notification_type, presence: true
scope :unread, lambda { where(read: false) }
scope :recent,
lambda { |n = nil|
n ||= 10
order("notifications.created_at desc").limit(n)
}
scope :visible,
lambda {
joins("LEFT JOIN topics ON notifications.topic_id = topics.id").where(
"topics.id IS NULL OR topics.deleted_at IS NULL",
)
}
scope :unread_type, ->(user, type, limit = 30) { unread_types(user, [type], limit) }
scope :unread_types,
->(user, types, limit = 30) do
where(user_id: user.id, read: false, notification_type: types)
.visible
.includes(:topic)
.limit(limit)
end
scope :prioritized,
->(deprioritized_types = []) do
scope = order("notifications.high_priority AND NOT notifications.read DESC")
if deprioritized_types.present?
scope =
scope.order(
DB.sql_fragment(
"NOT notifications.read AND notifications.notification_type NOT IN (?) DESC",
deprioritized_types,
),
)
else
scope = scope.order("NOT notifications.read DESC")
end
scope.order("notifications.created_at DESC")
end
scope :for_user_menu,
->(user_id, limit: 30) do
where(user_id: user_id).visible.prioritized.includes(:topic).limit(limit)
end
attr_accessor :skip_send_email
after_commit :refresh_notification_count, on: %i[create update destroy]
after_commit :send_email, on: :create
after_commit(on: :create) { DiscourseEvent.trigger(:notification_created, self) }
before_create do
# if we have manually set the notification to high_priority on create then
# make sure that is respected
self.high_priority =
self.high_priority || Notification.high_priority_types.include?(self.notification_type)
end
def self.consolidate_or_create!(notification_params)
notification = new(notification_params)
consolidation_planner = Notifications::ConsolidationPlanner.new
consolidated_notification = consolidation_planner.consolidate_or_save!(notification)
consolidated_notification == :no_plan ? notification.tap(&:save!) : consolidated_notification
end
def self.purge_old!
return if SiteSetting.max_notifications_per_user == 0
DB.exec(<<~SQL, SiteSetting.max_notifications_per_user)
DELETE FROM notifications n1
USING (
SELECT * FROM (
SELECT
user_id,
id,
rank() OVER (PARTITION BY user_id ORDER BY id DESC)
FROM notifications
) AS X
WHERE rank = ?
) n2
WHERE n1.user_id = n2.user_id AND n1.id < n2.id
SQL
end
def self.ensure_consistency!
DB.exec(<<~SQL)
DELETE
FROM notifications n
WHERE high_priority
AND n.topic_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM posts p
JOIN topics t ON t.id = p.topic_id
WHERE p.deleted_at IS NULL
AND t.deleted_at IS NULL
AND p.post_number = n.post_number
AND t.id = n.topic_id
)
SQL
end
def self.types
@types ||=
Enum.new(
mentioned: 1,
replied: 2,
quoted: 3,
edited: 4,
liked: 5,
private_message: 6,
invited_to_private_message: 7,
invitee_accepted: 8,
posted: 9,
moved_post: 10,
linked: 11,
granted_badge: 12,
invited_to_topic: 13,
custom: 14,
group_mentioned: 15,
group_message_summary: 16,
watching_first_post: 17,
topic_reminder: 18,
liked_consolidated: 19,
post_approved: 20,
code_review_commit_approved: 21,
membership_request_accepted: 22,
membership_request_consolidated: 23,
bookmark_reminder: 24,
reaction: 25,
votes_released: 26,
event_reminder: 27,
event_invitation: 28,
chat_mention: 29,
chat_message: 30,
chat_invitation: 31,
chat_group_mention: 32, # March 2022 - This is obsolete, as all chat_mentions use `chat_mention` type
chat_quoted: 33,
assigned: 34,
question_answer_user_commented: 35, # Used by https://github.com/discourse/discourse-question-answer
watching_category_or_tag: 36,
new_features: 37,
admin_problems: 38,
linked_consolidated: 39,
chat_watched_thread: 40,
upcoming_change_available: 41,
upcoming_change_automatically_promoted: 42,
following: 800, # Used by https://github.com/discourse/discourse-follow
following_created_topic: 801, # Used by https://github.com/discourse/discourse-follow
following_replied: 802, # Used by https://github.com/discourse/discourse-follow
circles_activity: 900, # Used by https://github.com/discourse/discourse-circles
)
end
def self.high_priority_types
@high_priority_types ||= [types[:private_message], types[:bookmark_reminder]]
end
def self.normal_priority_types
@normal_priority_types ||= types.reject { |_k, v| high_priority_types.include?(v) }.values
end
def self.mark_posts_read(user, topic_id, post_numbers)
Notification.where(
user_id: user.id,
topic_id: topic_id,
post_number: post_numbers,
read: false,
).update_all(read: true)
end
def self.read(user, notification_ids)
Notification.where(id: notification_ids, user_id: user.id, read: false).update_all(read: true)
end
def self.read_types(user, types = nil)
query = Notification.where(user_id: user.id, read: false)
query = query.where(notification_type: types) if types
query.update_all(read: true)
end
def self.interesting_after(min_date)
result =
where("created_at > ?", min_date)
.includes(:topic)
.visible
.unread
.limit(20)
.order(
"CASE WHEN notification_type = #{Notification.types[:replied]} THEN 1
WHEN notification_type = #{Notification.types[:mentioned]} THEN 2
ELSE 3
END, created_at DESC",
)
.to_a
# Remove any duplicates by type and topic
if result.present?
seen = {}
to_remove = Set.new
result.each do |r|
seen[r.notification_type] ||= Set.new
if seen[r.notification_type].include?(r.topic_id)
to_remove << r.id
else
seen[r.notification_type] << r.topic_id
end
end
result.reject! { |r| to_remove.include?(r.id) }
end
result
end
# Clean up any notifications the user can no longer see. For example, if a topic was previously
# public then turns private.
def self.remove_for(user_id, topic_id)
Notification.where(user_id: user_id, topic_id: topic_id).delete_all
end
def self.filter_inaccessible_topic_notifications(guardian, notifications)
topic_ids = notifications.map { |n| n.topic_id }.compact.uniq
accessible_topic_ids = guardian.can_see_topic_ids(topic_ids: topic_ids)
notifications.select { |n| n.topic_id.blank? || accessible_topic_ids.include?(n.topic_id) }
end
def self.filter_disabled_badge_notifications(notifications)
return notifications if notifications.blank?
if !SiteSetting.enable_badges
return notifications.reject { |n| n.notification_type == types[:granted_badge] }
end
badge_ids =
notifications.filter_map do |n|
n.data_hash[:badge_id] if n.notification_type == types[:granted_badge]
end
return notifications if badge_ids.empty?
enabled_badge_ids = Badge.where(id: badge_ids, enabled: true).pluck(:id).to_set
notifications.reject do |n|
n.notification_type == types[:granted_badge] &&
!enabled_badge_ids.include?(n.data_hash[:badge_id])
end
end
# Be wary of calling this frequently. O(n) JSON parsing can suck.
def data_hash
@data_hash ||=
begin
return {} if data.blank?
parsed = JSON.parse(data)
return {} if parsed.blank?
parsed.with_indifferent_access
end
end
def url
topic.relative_url(post_number) if topic.present?
end
def post
return if topic_id.blank? || post_number.blank?
Post.find_by(topic_id: topic_id, post_number: post_number)
end
# Update `index_notifications_user_menu_ordering_deprioritized_likes` index when updating this as this is used by
# `Notification.prioritized_list` to deprioritize like typed notifications. Also See
# `db/migrate/20240306063428_add_indexes_to_notifications.rb`.
def self.like_types
[
Notification.types[:liked],
Notification.types[:liked_consolidated],
Notification.types[:reaction],
]
end
def self.prioritized_list(user, count: 30, types: [])
return [] if !user&.user_option
notifications =
user
.notifications
.includes(:topic)
.visible
.prioritized(types.present? ? [] : like_types)
.limit(count)
if types.present?
notifications = notifications.where(notification_type: types)
elsif user.user_option.like_notification_frequency ==
UserOption.like_notification_frequency_type[:never]
like_types.each do |notification_type|
notifications = notifications.where.not(notification_type:)
end
end
notifications.to_a
end
def self.recent_report(user, count = nil, types = [])
return unless user && user.user_option
count ||= 10
notifications = user.notifications.visible.recent(count).includes(:topic)
notifications = notifications.where(notification_type: types) if types.present?
if user.user_option.like_notification_frequency ==
UserOption.like_notification_frequency_type[:never]
[
Notification.types[:liked],
Notification.types[:liked_consolidated],
].each { |notification_type| notifications = notifications.where.not(notification_type:) }
end
notifications = notifications.to_a
if notifications.present?
builder = DB.build(<<~SQL)
SELECT n.id FROM notifications n
/*where*/
ORDER BY n.id ASC
/*limit*/
SQL
builder.where(<<~SQL, user_id: user.id)
n.high_priority = TRUE AND
n.user_id = :user_id AND
NOT read
SQL
builder.where("notification_type IN (:types)", types: types) if types.present?
builder.limit(count.to_i)
ids = builder.query_single
if ids.length > 0
notifications +=
user
.notifications
.order("notifications.created_at DESC")
.where(id: ids)
.joins(:topic)
.limit(count)
end
notifications
.uniq(&:id)
.sort do |x, y|
if x.unread_high_priority? && !y.unread_high_priority?
-1
elsif y.unread_high_priority? && !x.unread_high_priority?
1
else
y.created_at <=> x.created_at
end
end
.take(count)
else
[]
end
end
def self.populate_acting_user(notifications)
if !(SiteSetting.show_user_menu_avatars || SiteSetting.prioritize_full_name_in_ux)
return notifications
end
usernames =
notifications.map do |notification|
notification.acting_username =
(
notification.data_hash[:username] || notification.data_hash[:display_username] ||
notification.data_hash[:mentioned_by_username] ||
notification.data_hash[:invited_by_username] ||
notification.data_hash[:original_username]
)&.downcase
end
users = User.where(username_lower: usernames.uniq).index_by(&:username_lower)
notifications.each do |notification|
notification.acting_user = users[notification.acting_username]
notification.data_hash[
:original_name
] = notification.acting_user&.name if SiteSetting.enable_names
end
notifications
end
def unread_high_priority?
self.high_priority? && !read
end
def post_id
Post.where(topic: topic_id, post_number: post_number).pick(:id)
end
protected
def refresh_notification_count
User.find_by(id: user_id)&.publish_notifications_state if user_id
end
def send_email
return if skip_send_email
if user.do_not_disturb?
ShelvedNotification.create(notification_id: self.id)
else
NotificationEmailer.process_notification(self)
end
end
end
# == Schema Information
#
# Table name: notifications
#
# notification_type :integer not null
# user_id :integer not null
# data :string(1000) not null
# read :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# topic_id :integer
# post_number :integer
# post_action_id :integer
# high_priority :boolean default(FALSE), not null
# id :bigint not null, primary key
#
# Indexes
#
# idx_notifications_speedup_unread_count (user_id,notification_type) WHERE (NOT read)
# index_notifications_on_data_display_username ((((data)::jsonb ->> 'display_username'::text))) WHERE (((data)::jsonb ->> 'display_username'::text) IS NOT NULL)
# index_notifications_on_data_original_username ((((data)::jsonb ->> 'original_username'::text))) WHERE (((data)::jsonb ->> 'original_username'::text) IS NOT NULL)
# index_notifications_on_data_username ((((data)::jsonb ->> 'username'::text))) WHERE (((data)::jsonb ->> 'username'::text) IS NOT NULL)
# index_notifications_on_data_username2 ((((data)::jsonb ->> 'username2'::text))) WHERE (((data)::jsonb ->> 'username2'::text) IS NOT NULL)
# index_notifications_on_post_action_id (post_action_id)
# index_notifications_on_topic_id_and_post_number (topic_id,post_number)
# index_notifications_on_user_id_and_created_at (user_id,created_at)
# index_notifications_on_user_id_and_topic_id_and_post_number (user_id,topic_id,post_number)
# index_notifications_read_or_not_high_priority (user_id,id DESC,read,topic_id) WHERE (read OR (high_priority = false))
# index_notifications_unique_unread_high_priority (user_id,id) UNIQUE WHERE ((NOT read) AND (high_priority = true))
# index_notifications_user_menu_ordering (user_id, ((high_priority AND (NOT read))) DESC, ((NOT read)) DESC, created_at DESC)
# index_notifications_user_menu_ordering_deprioritized_likes (user_id, ((high_priority AND (NOT read))) DESC, (((NOT read) AND (notification_type <> ALL (ARRAY[5, 19, 25])))) DESC, created_at DESC)
#