Joffrey JAFFEUX c5aa6b5e16
FIX: correctly update replies_count on chat_threads (#24711)
The previous query would look at the existing messages, count them, and update the associated thread.

But, if for some reason messages were **ALL** deleted without updating the `replies_count`, then the query wouldn't find any message, and wouldn't update any thread's `replies_count`.
2023-12-05 13:46:58 +01:00

171 lines
6.0 KiB
Ruby

# frozen_string_literal: true
module Chat
class Thread < ActiveRecord::Base
EXCERPT_LENGTH = 150
MAX_TITLE_LENGTH = 100
include Chat::ThreadCache
self.table_name = "chat_threads"
belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel"
belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User"
belongs_to :original_message,
-> { with_deleted },
foreign_key: "original_message_id",
class_name: "Chat::Message"
has_many :chat_messages,
-> do
where("deleted_at IS NULL").order(
"chat_messages.created_at ASC, chat_messages.id ASC",
)
end,
foreign_key: :thread_id,
primary_key: :id,
class_name: "Chat::Message"
has_many :user_chat_thread_memberships
belongs_to :last_message,
class_name: "Chat::Message",
foreign_key: :last_message_id,
optional: true
def last_message
super || NullMessage.new
end
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
# Since the `replies` for the thread can all be deleted, to avoid errors
# in lists and previews of the thread, we can consider the original message
# as the last message in this case as a fallback.
before_create { self.last_message_id = self.original_message_id }
def add(user)
Chat::UserChatThreadMembership.find_or_create_by!(user: user, thread: self)
end
def remove(user)
Chat::UserChatThreadMembership.find_by(user: user, thread: self)&.destroy
end
def membership_for(user)
user_chat_thread_memberships.find_by(user: user)
end
def mark_read_for_user!(user, last_read_message_id: nil)
membership_for(user)&.update!(
last_read_message_id: last_read_message_id || self.last_message_id,
)
end
def replies
self.chat_messages.where.not(id: self.original_message_id).order("created_at ASC, id ASC")
end
def url
"#{channel.url}/t/#{self.id}"
end
def relative_url
"#{channel.relative_url}/t/#{self.id}"
end
def excerpt
original_message.excerpt(max_length: EXCERPT_LENGTH)
end
def update_last_message_id!
self.update!(last_message_id: self.latest_not_deleted_message_id)
end
def latest_not_deleted_message_id(anchor_message_id: nil)
DB.query_single(
<<~SQL,
SELECT id FROM chat_messages
WHERE chat_channel_id = :channel_id
AND thread_id = :thread_id
AND deleted_at IS NULL
#{anchor_message_id ? "AND id < :anchor_message_id" : ""}
ORDER BY created_at DESC, id DESC
LIMIT 1
SQL
channel_id: self.channel_id,
thread_id: self.id,
anchor_message_id: anchor_message_id,
).first
end
def self.grouped_messages(thread_ids: nil, message_ids: nil, include_original_message: true)
DB.query(<<~SQL, message_ids: message_ids, thread_ids: thread_ids)
SELECT thread_id,
array_agg(chat_messages.id ORDER BY chat_messages.created_at, chat_messages.id) AS thread_message_ids,
chat_threads.original_message_id
FROM chat_messages
INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id
WHERE thread_id IS NOT NULL
#{thread_ids ? "AND thread_id IN (:thread_ids)" : ""}
#{message_ids ? "AND chat_messages.id IN (:message_ids)" : ""}
#{include_original_message ? "" : "AND chat_messages.id != chat_threads.original_message_id"}
GROUP BY thread_id, chat_threads.original_message_id;
SQL
end
def self.ensure_consistency!
update_counts
end
def self.update_counts
# NOTE: Chat::Thread#replies_count is not updated every time
# a message is created or deleted in a channel, the UI will lag
# behind unless it is kept in sync with MessageBus. The count
# has 1 subtracted from it to account for the original message.
#
# It is updated eventually via Jobs::Chat::PeriodicalUpdates. In
# future we may want to update this more frequently.
updated_thread_ids = DB.query_single <<~SQL
UPDATE chat_threads ct
SET replies_count = GREATEST(COALESCE(subquery.new_count, 0), 0)
FROM (
SELECT cm.thread_id, COUNT(cm.*) - 1 AS new_count
FROM chat_threads
LEFT JOIN chat_messages cm ON cm.thread_id = chat_threads.id AND cm.deleted_at IS NULL
GROUP BY cm.thread_id
) AS subquery
WHERE ct.id = subquery.thread_id AND ct.replies_count IS DISTINCT FROM GREATEST(COALESCE(subquery.new_count, 0), 0)
RETURNING ct.id AS thread_id
SQL
return if updated_thread_ids.empty?
self.clear_caches!(updated_thread_ids)
end
end
end
# == Schema Information
#
# Table name: chat_threads
#
# id :bigint not null, primary key
# channel_id :integer not null
# original_message_id :integer not null
# original_message_user_id :integer not null
# status :integer default("open"), not null
# title :string
# created_at :datetime not null
# updated_at :datetime not null
# replies_count :integer default(0), not null
# last_message_id :bigint
#
# Indexes
#
# index_chat_threads_on_channel_id (channel_id)
# index_chat_threads_on_channel_id_and_status (channel_id,status)
# index_chat_threads_on_last_message_id (last_message_id)
# index_chat_threads_on_original_message_id (original_message_id)
# index_chat_threads_on_original_message_user_id (original_message_user_id)
# index_chat_threads_on_replies_count (replies_count)
# index_chat_threads_on_status (status)
#