FIX: Better tracking of topic visibility changes (#26709)

This commit introduces a few changes as a result of
customer issues with finding why a topic was relisted.
In one case, if a user edited the OP of a topic that was
unlisted and hidden because of too many flags, the topic
would get relisted by directly changing topic.visible,
instead of going via TopicStatusUpdater.

To improve tracking we:

* Introduce a visibility_reason_id to topic which functions
  in a similar way to hidden_reason_id on post, this column is
  set from the various places we change topic visibility
* Fix Post#unhide! which was directly modifying topic.visible,
  instead we use TopicStatusUpdater which sets visibility_reason_id
  and also makes a small action post
* Show the reason topic visibility changed when hovering the
  unlisted icon in topic status on topic titles
This commit is contained in:
Martin Brennan 2024-04-29 10:34:46 +10:00 committed by GitHub
parent acc5b01e21
commit edec941a87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 229 additions and 19 deletions

View File

@ -82,7 +82,17 @@ export default Component.extend({
_set(name, icon, key, iconArgs) { _set(name, icon, key, iconArgs) {
this.set(`${name}Icon`, htmlSafe(iconHTML(`${icon}`, iconArgs))); this.set(`${name}Icon`, htmlSafe(iconHTML(`${icon}`, iconArgs)));
this.set(`${name}Title`, I18n.t(`topic_statuses.${key}.help`));
const translationParams = {};
if (name === "invisible") {
translationParams.unlistedReason = this.topic.visibilityReasonTranslated;
}
this.set(
`${name}Title`,
I18n.t(`topic_statuses.${key}.help`, translationParams)
);
return true; return true;
}, },

View File

@ -70,3 +70,13 @@ export const AUTO_GROUPS = {
}; };
export const MAX_NOTIFICATIONS_LIMIT_PARAMS = 60; export const MAX_NOTIFICATIONS_LIMIT_PARAMS = 60;
export const TOPIC_VISIBILITY_REASONS = {
op_flag_threshold_reached: 0,
op_unhidden: 1,
embedded_topic: 2,
manually_unlisted: 3,
manually_relisted: 4,
bulk_action: 5,
unknown: 99,
};

View File

@ -5,6 +5,7 @@ import { resolveShareUrl } from "discourse/helpers/share-url";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { fmt, propertyEqual } from "discourse/lib/computed"; import { fmt, propertyEqual } from "discourse/lib/computed";
import { TOPIC_VISIBILITY_REASONS } from "discourse/lib/constants";
import { longDate } from "discourse/lib/formatter"; import { longDate } from "discourse/lib/formatter";
import { applyModelTransformations } from "discourse/lib/model-transformers"; import { applyModelTransformations } from "discourse/lib/model-transformers";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
@ -461,6 +462,21 @@ export default class Topic extends RestModel {
return visible !== undefined ? !visible : undefined; return visible !== undefined ? !visible : undefined;
} }
@discourseComputed("visibility_reason_id")
visibilityReasonTranslated() {
if (
this.visibility_reason_id &&
this.visibility_reason_id !== TOPIC_VISIBILITY_REASONS.unknown
) {
const reasonKey = Object.keys(TOPIC_VISIBILITY_REASONS).find(
(key) => TOPIC_VISIBILITY_REASONS[key] === this.visibility_reason_id
);
return I18n.t(`topic_statuses.visibility_reasons.${reasonKey}`);
}
return "";
}
@discourseComputed("id") @discourseComputed("id")
searchContext(id) { searchContext(id) {
return { type: "topic", id }; return { type: "topic", id };

View File

@ -67,7 +67,17 @@ export default EmberObject.extend({
} }
results.forEach((result) => { results.forEach((result) => {
result.title = I18n.t(`topic_statuses.${result.key}.help`); const translationParams = {};
if (result.key === "unlisted") {
translationParams.unlistedReason = topic.visibilityReasonTranslated;
}
result.title = I18n.t(
`topic_statuses.${result.key}.help`,
translationParams
);
if ( if (
this.currentUser && this.currentUser &&
(result.key === "pinned" || result.key === "unpinned") (result.key === "pinned" || result.key === "unpinned")

View File

@ -497,6 +497,18 @@ class TopicsController < ApplicationController
Topic.find_by(id: topic_id) Topic.find_by(id: topic_id)
end end
status_opts = { until: params[:until].presence }
if status == "visible"
status_opts[:visibility_reason_id] = (
if enabled
Topic.visibility_reasons[:manually_relisted]
else
Topic.visibility_reasons[:manually_unlisted]
end
)
end
case status case status
when "closed" when "closed"
guardian.ensure_can_close_topic!(@topic) guardian.ensure_can_close_topic!(@topic)
@ -510,9 +522,7 @@ class TopicsController < ApplicationController
guardian.ensure_can_moderate!(@topic) guardian.ensure_can_moderate!(@topic)
end end
params[:until] === "" ? params[:until] = nil : params[:until] @topic.update_status(status, enabled, current_user, status_opts)
@topic.update_status(status, enabled, current_user, until: params[:until])
render json: render json:
success_json.merge!( success_json.merge!(

View File

@ -6,7 +6,12 @@ module Jobs
raise Discourse::InvalidParameters.new(:topic_id) if args[:topic_id].blank? raise Discourse::InvalidParameters.new(:topic_id) if args[:topic_id].blank?
if topic = Topic.find_by(id: args[:topic_id]) if topic = Topic.find_by(id: args[:topic_id])
topic.update_status("visible", true, topic.user) topic.update_status(
"visible",
true,
topic.user,
{ visibility_reason_id: Topic.visibility_reasons[:embedded_topic] },
)
end end
end end
end end

View File

@ -612,15 +612,26 @@ class Post < ActiveRecord::Base
Post.transaction do Post.transaction do
self.skip_validation = true self.skip_validation = true
should_update_user_stat = true
update!(hidden: true, hidden_at: Time.zone.now, hidden_reason_id: reason) update!(hidden: true, hidden_at: Time.zone.now, hidden_reason_id: reason)
Topic.where( any_visible_posts_in_topic =
"id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", Post.exists?(topic_id: topic_id, hidden: false, post_type: Post.types[:regular])
topic_id: topic_id,
).update_all(visible: false)
UserStatCountUpdater.decrement!(self) if !any_visible_posts_in_topic
self.topic.update_status(
"visible",
false,
Discourse.system_user,
{ visibility_reason_id: Topic.visibility_reasons[:op_flag_threshold_reached] },
)
should_update_user_stat = false
end
# We need to do this because TopicStatusUpdater also does the decrement
# and we don't want to double count for the OP.
UserStatCountUpdater.decrement!(self) if should_update_user_stat
end end
# inform user # inform user
@ -652,8 +663,29 @@ class Post < ActiveRecord::Base
def unhide! def unhide!
Post.transaction do Post.transaction do
self.update!(hidden: false) self.update!(hidden: false)
self.topic.update(visible: true) if is_first_post? should_update_user_stat = true
UserStatCountUpdater.increment!(self)
# NOTE: We have to consider `nil` a valid reason here because historically
# topics didn't have a visibility_reason_id, if we didn't do this we would
# break backwards compat since we cannot backfill data.
hidden_because_of_op_flagging =
self.topic.visibility_reason_id == Topic.visibility_reasons[:op_flag_threshold_reached] ||
self.topic.visibility_reason_id.nil?
if is_first_post? && hidden_because_of_op_flagging
self.topic.update_status(
"visible",
true,
Discourse.system_user,
{ visibility_reason_id: Topic.visibility_reasons[:op_unhidden] },
)
should_update_user_stat = false
end
# We need to do this because TopicStatusUpdater also does the increment
# and we don't want to double count for the OP.
UserStatCountUpdater.increment!(self) if should_update_user_stat
save(validate: false) save(validate: false)
end end

View File

@ -43,6 +43,19 @@ class Topic < ActiveRecord::Base
[self.share_thumbnail_size] + DiscoursePluginRegistry.topic_thumbnail_sizes [self.share_thumbnail_size] + DiscoursePluginRegistry.topic_thumbnail_sizes
end end
def self.visibility_reasons
@visible_reasons ||=
Enum.new(
op_flag_threshold_reached: 0,
op_unhidden: 1,
embedded_topic: 2,
manually_unlisted: 3,
manually_relisted: 4,
bulk_action: 5,
unknown: 99,
)
end
def thumbnail_job_redis_key(sizes) def thumbnail_job_redis_key(sizes)
"generate_topic_thumbnail_enqueue_#{id}_#{sizes.inspect}" "generate_topic_thumbnail_enqueue_#{id}_#{sizes.inspect}"
end end
@ -2178,6 +2191,7 @@ end
# slow_mode_seconds :integer default(0), not null # slow_mode_seconds :integer default(0), not null
# bannered_until :datetime # bannered_until :datetime
# external_id :string # external_id :string
# visibility_reason_id :integer
# #
# Indexes # Indexes
# #

View File

@ -26,7 +26,8 @@ class ListableTopicSerializer < BasicTopicSerializer
:liked, :liked,
:unicode_title, :unicode_title,
:unread_by_group_member, :unread_by_group_member,
:thumbnails :thumbnails,
:visibility_reason_id
has_one :last_poster, serializer: BasicUserSerializer, embed: :objects has_one :last_poster, serializer: BasicUserSerializer, embed: :objects
@ -159,6 +160,10 @@ class ListableTopicSerializer < BasicTopicSerializer
!!object.topic_list&.publish_read_state !!object.topic_list&.publish_read_state
end end
def include_visibility_reason_id?
object.visibility_reason_id.present?
end
protected protected
def unread_helper def unread_helper

View File

@ -43,6 +43,7 @@ class TopicViewSerializer < ApplicationSerializer
:image_url, :image_url,
:slow_mode_seconds, :slow_mode_seconds,
:external_id, :external_id,
:visibility_reason_id,
) )
attributes( attributes(
@ -321,4 +322,8 @@ class TopicViewSerializer < ApplicationSerializer
def include_categories? def include_categories?
scope.can_lazy_load_categories? scope.can_lazy_load_categories?
end end
def include_visibility_reason_id?
object.topic.visibility_reason_id.present?
end
end end

View File

@ -55,6 +55,12 @@ TopicStatusUpdater =
) )
end end
if status.visible?
topic.update(
visibility_reason_id: opts[:visibility_reason_id] || Topic.visibility_reasons[:unknown],
)
end
if @topic_timer if @topic_timer
if status.manually_closing_topic? || status.closing_topic? if status.manually_closing_topic? || status.closing_topic?
topic.delete_topic_timer(TopicTimer.types[:close]) topic.delete_topic_timer(TopicTimer.types[:close])

View File

@ -4084,10 +4084,17 @@ en:
title: "Pinned" title: "Pinned"
help: "This topic is pinned for you; it will display at the top of its category" help: "This topic is pinned for you; it will display at the top of its category"
unlisted: unlisted:
help: "This topic is unlisted; it will not be displayed in topic lists, and can only be accessed via a direct link" help: "This topic is unlisted; it will not be displayed in topic lists, and can only be accessed via a direct link. %{unlistedReason}"
personal_message: personal_message:
title: "This topic is a personal message" title: "This topic is a personal message"
help: "This topic is a personal message" help: "This topic is a personal message"
visibility_reasons:
op_flag_threshold_reached: "This topic was automatically unlisted because it reached the flag threshold"
op_unhidden: "This topic was relisted by the author"
embedded_topic: "This topic was relisted because it is an embedded topic"
manually_unlisted: "This topic was manually unlisted by an admin or moderator"
manually_relisted: "This topic was manually relisted by an admin or moderator"
bulk_action: "This topic's visibility was changed because of a bulk action performed by a user"
posts: "Posts" posts: "Posts"
pending_posts: pending_posts:
label: "Pending" label: "Pending"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddVisibilityReasonIdToTopics < ActiveRecord::Migration[7.0]
def change
add_column :topics, :visibility_reason_id, :integer
end
end

View File

@ -235,7 +235,14 @@ class PostActionCreator
return if not_auto_action_flag_type && !@queue_for_review return if not_auto_action_flag_type && !@queue_for_review
if @queue_for_review if @queue_for_review
@post.topic.update_status("visible", false, @created_by) if @post.is_first_post? if @post.is_first_post?
@post.topic.update_status(
"visible",
false,
@created_by,
{ visibility_reason_id: Topic.visibility_reasons[:op_flag_threshold_reached] },
)
end
@post.hide!( @post.hide!(
@post_action_type_id, @post_action_type_id,

View File

@ -162,6 +162,8 @@ task "javascript:update_constants" => :environment do
export const AUTO_GROUPS = #{auto_groups.to_json}; export const AUTO_GROUPS = #{auto_groups.to_json};
export const MAX_NOTIFICATIONS_LIMIT_PARAMS = #{NotificationsController::INDEX_LIMIT}; export const MAX_NOTIFICATIONS_LIMIT_PARAMS = #{NotificationsController::INDEX_LIMIT};
export const TOPIC_VISIBILITY_REASONS = #{Topic.visibility_reasons.to_json};
JS JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n") pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -183,7 +183,12 @@ class TopicsBulkAction
def unlist def unlist
topics.each do |t| topics.each do |t|
if guardian.can_moderate?(t) if guardian.can_moderate?(t)
t.update_status("visible", false, @user) t.update_status(
"visible",
false,
@user,
{ visibility_reason_id: Topic.visibility_reasons[:bulk_action] },
)
@changed_ids << t.id @changed_ids << t.id
end end
end end
@ -192,7 +197,12 @@ class TopicsBulkAction
def relist def relist
topics.each do |t| topics.each do |t|
if guardian.can_moderate?(t) if guardian.can_moderate?(t)
t.update_status("visible", true, @user) t.update_status(
"visible",
true,
@user,
{ visibility_reason_id: Topic.visibility_reasons[:bulk_action] },
)
@changed_ids << t.id @changed_ids << t.id
end end
end end

View File

@ -544,6 +544,9 @@ RSpec.describe PostAction do
expect(post.hidden_at).to be_present expect(post.hidden_at).to be_present
expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached]) expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached])
expect(post.topic.visible).to eq(false) expect(post.topic.visible).to eq(false)
expect(post.topic.visibility_reason_id).to eq(
Topic.visibility_reasons[:op_flag_threshold_reached],
)
post.revise(post.user, raw: post.raw + " ha I edited it ") post.revise(post.user, raw: post.raw + " ha I edited it ")
@ -553,6 +556,7 @@ RSpec.describe PostAction do
expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached]) # keep most recent reason expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached]) # keep most recent reason
expect(post.hidden_at).to be_present # keep the most recent hidden_at time expect(post.hidden_at).to be_present # keep the most recent hidden_at time
expect(post.topic.visible).to eq(true) expect(post.topic.visible).to eq(true)
expect(post.topic.visibility_reason_id).to eq(Topic.visibility_reasons[:op_unhidden])
PostActionCreator.spam(eviltrout, post) PostActionCreator.spam(eviltrout, post)
PostActionCreator.off_topic(walterwhite, post) PostActionCreator.off_topic(walterwhite, post)
@ -567,6 +571,9 @@ RSpec.describe PostAction do
expect(post.hidden_at).to be_present expect(post.hidden_at).to be_present
expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached_again]) expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached_again])
expect(post.topic.visible).to eq(false) expect(post.topic.visible).to eq(false)
expect(post.topic.visibility_reason_id).to eq(
Topic.visibility_reasons[:op_flag_threshold_reached],
)
post.revise(post.user, raw: post.raw + " ha I edited it again ") post.revise(post.user, raw: post.raw + " ha I edited it again ")
@ -575,7 +582,10 @@ RSpec.describe PostAction do
expect(post.hidden).to eq(true) expect(post.hidden).to eq(true)
expect(post.hidden_at).to be_present expect(post.hidden_at).to be_present
expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached_again]) expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:flag_threshold_reached_again])
expect(post.topic.visible).to eq(false) expect(post.topic.reload.visible).to eq(false)
expect(post.topic.visibility_reason_id).to eq(
Topic.visibility_reasons[:op_flag_threshold_reached],
)
end end
it "doesn't fail when post has nil user" do it "doesn't fail when post has nil user" do

View File

@ -1540,6 +1540,25 @@ RSpec.describe Post do
expect(post.hidden).to eq(false) expect(post.hidden).to eq(false)
expect(hidden_topic.visible).to eq(true) expect(hidden_topic.visible).to eq(true)
expect(hidden_topic.visibility_reason_id).to eq(Topic.visibility_reasons[:op_unhidden])
end
it "will not unhide the topic if the topic visibility_reason_id is not op_flag_threshold_reached" do
hidden_topic =
Fabricate(
:topic,
visible: false,
visibility_reason_id: Topic.visibility_reasons[:manually_unlisted],
)
post = create_post(topic: hidden_topic)
post.update_columns(hidden: true, hidden_at: Time.now, hidden_reason_id: 1)
post.reload
expect(post.hidden).to eq(true)
post.unhide!
hidden_topic.reload
expect(hidden_topic.visible).to eq(false)
end end
it "should increase user_stat topic_count for first post" do it "should increase user_stat topic_count for first post" do

View File

@ -1224,6 +1224,9 @@ RSpec.describe TopicsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(topic.reload.visible).to eq(false) expect(topic.reload.visible).to eq(false)
expect(topic.reload.visibility_reason_id).to eq(
Topic.visibility_reasons[:manually_unlisted],
)
expect(topic.posts.last.action_code).to eq("visible.disabled") expect(topic.posts.last.action_code).to eq("visible.disabled")
end end
@ -1234,6 +1237,9 @@ RSpec.describe TopicsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(topic.reload.visible).to eq(true) expect(topic.reload.visible).to eq(true)
expect(topic.reload.visibility_reason_id).to eq(
Topic.visibility_reasons[:manually_relisted],
)
expect(topic.posts.last.action_code).to eq("visible.enabled") expect(topic.posts.last.action_code).to eq("visible.enabled")
end end
end end

View File

@ -204,5 +204,24 @@ RSpec.describe TopicStatusUpdater do
expect(updated).to eq(false) expect(updated).to eq(false)
expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2) expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2)
end end
it "sets visibility_reason_id" do
topic = Fabricate(:topic)
updated = TopicStatusUpdater.new(topic, admin).update!("visible", false)
expect(updated).to eq(true)
expect(topic.visible).to eq(false)
expect(topic.visibility_reason_id).to eq(Topic.visibility_reasons[:unknown])
updated =
TopicStatusUpdater.new(topic, admin).update!(
"visible",
true,
{ visibility_reason_id: Topic.visibility_reasons[:manually_relisted] },
)
expect(updated).to eq(true)
expect(topic.visible).to eq(true)
expect(topic.visibility_reason_id).to eq(Topic.visibility_reasons[:manually_relisted])
end
end end
end end