FEATURE: Remove user topic timers and migrate to bookmarks with reminders (#10474)

This PR removes the user reminder topic timers, because that system has been supplanted and improved by bookmark reminders. The option is removed from the UI and all existing user reminder topic timers are migrated to bookmark reminders.

Migration does this:

* Get all topic_timers with status_type 5 (reminders)
* Gets all bookmarks where the user ID and topic ID match
* Loops through the found topic timers
  * If there is no bookmark for the OP of the topic, then we just create a bookmark with a reminder
  * If there is a bookmark for the OP of the topic and it does **not** have a reminder set, then just 
update it with the topic timer reminder
  * If there is a bookmark for the OP of the topic with a reminder then just discard the topic timer
* Cancels all outstanding user reminder topic timers
* **Trashes (not deletes) all user reminder topic timers**

Notes:

* For now I have left the user reminder topic timer job class in place; this is so the jobs can be cancelled in the migration. It and the specs will be deleted in the next PR.
* At a later date I will write a migration to delete all trashed user topic timers. They are not deleted here in case there are data issues and they need to be recovered.
* A future PR will change the UI of the topic timer modal to make it look more like the bookmark modal.
This commit is contained in:
Martin Brennan 2020-09-14 11:11:55 +10:00 committed by GitHub
parent 333ddd4011
commit 5268568d23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 91 additions and 234 deletions

View File

@ -6,7 +6,6 @@ import {
PUBLISH_TO_CATEGORY_STATUS_TYPE, PUBLISH_TO_CATEGORY_STATUS_TYPE,
OPEN_STATUS_TYPE, OPEN_STATUS_TYPE,
DELETE_STATUS_TYPE, DELETE_STATUS_TYPE,
REMINDER_TYPE,
CLOSE_STATUS_TYPE, CLOSE_STATUS_TYPE,
BUMP_TYPE, BUMP_TYPE,
DELETE_REPLIES_TYPE, DELETE_REPLIES_TYPE,
@ -19,9 +18,8 @@ export default Component.extend({
autoDelete: equal("selection", DELETE_STATUS_TYPE), autoDelete: equal("selection", DELETE_STATUS_TYPE),
autoBump: equal("selection", BUMP_TYPE), autoBump: equal("selection", BUMP_TYPE),
publishToCategory: equal("selection", PUBLISH_TO_CATEGORY_STATUS_TYPE), publishToCategory: equal("selection", PUBLISH_TO_CATEGORY_STATUS_TYPE),
reminder: equal("selection", REMINDER_TYPE),
autoDeleteReplies: equal("selection", DELETE_REPLIES_TYPE), autoDeleteReplies: equal("selection", DELETE_REPLIES_TYPE),
showTimeOnly: or("autoOpen", "autoDelete", "reminder", "autoBump"), showTimeOnly: or("autoOpen", "autoDelete", "autoBump"),
showFutureDateInput: or( showFutureDateInput: or(
"showTimeOnly", "showTimeOnly",
"publishToCategory", "publishToCategory",

View File

@ -4,10 +4,7 @@ import { cancel, later } from "@ember/runloop";
import Component from "@ember/component"; import Component from "@ember/component";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import { import { DELETE_REPLIES_TYPE } from "discourse/controllers/edit-topic-timer";
REMINDER_TYPE,
DELETE_REPLIES_TYPE,
} from "discourse/controllers/edit-topic-timer";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
export default Component.extend({ export default Component.extend({
@ -20,9 +17,8 @@ export default Component.extend({
notice: null, notice: null,
showTopicTimer: null, showTopicTimer: null,
@discourseComputed("statusType") @discourseComputed
canRemoveTimer(type) { canRemoveTimer() {
if (type === REMINDER_TYPE) return true;
return this.currentUser && this.currentUser.get("canManageTopic"); return this.currentUser && this.currentUser.get("canManageTopic");
}, },

View File

@ -1,4 +1,5 @@
import I18n from "I18n"; import I18n from "I18n";
import { alias } from "@ember/object/computed";
import EmberObject, { setProperties } from "@ember/object"; import EmberObject, { setProperties } from "@ember/object";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
@ -11,7 +12,6 @@ export const CLOSE_STATUS_TYPE = "close";
export const OPEN_STATUS_TYPE = "open"; export const OPEN_STATUS_TYPE = "open";
export const PUBLISH_TO_CATEGORY_STATUS_TYPE = "publish_to_category"; export const PUBLISH_TO_CATEGORY_STATUS_TYPE = "publish_to_category";
export const DELETE_STATUS_TYPE = "delete"; export const DELETE_STATUS_TYPE = "delete";
export const REMINDER_TYPE = "reminder";
export const BUMP_TYPE = "bump"; export const BUMP_TYPE = "bump";
export const DELETE_REPLIES_TYPE = "delete_replies"; export const DELETE_REPLIES_TYPE = "delete_replies";
@ -58,24 +58,7 @@ export default Controller.extend(ModalFunctionality, {
return types; return types;
}, },
@discourseComputed() topicTimer: alias("model.topic_timer"),
privateTimerTypes() {
return [{ id: REMINDER_TYPE, name: I18n.t("topic.reminder.title") }];
},
@discourseComputed("isPublic", "publicTimerTypes", "privateTimerTypes")
selections(isPublic, publicTimerTypes, privateTimerTypes) {
return "true" === isPublic ? publicTimerTypes : privateTimerTypes;
},
@discourseComputed(
"isPublic",
"model.topic_timer",
"model.private_topic_timer"
)
topicTimer(isPublic, publicTopicTimer, privateTopicTimer) {
return "true" === isPublic ? publicTopicTimer : privateTopicTimer;
},
_setTimer(time, duration, statusType, basedOnLastPost, categoryId) { _setTimer(time, duration, statusType, basedOnLastPost, categoryId) {
this.set("loading", true); this.set("loading", true);
@ -100,9 +83,7 @@ export default Controller.extend(ModalFunctionality, {
this.set("model.closed", result.closed); this.set("model.closed", result.closed);
} else { } else {
const topicTimer = this.set("model.topic_timer", EmberObject.create({}));
this.isPublic === "true" ? "topic_timer" : "private_topic_timer";
this.set(`model.${topicTimer}`, EmberObject.create({}));
this.setProperties({ this.setProperties({
selection: null, selection: null,

View File

@ -114,11 +114,6 @@ const TopicRoute = DiscourseRoute.extend({
model.set("topic_timer", {}); model.set("topic_timer", {});
} }
const privateTopicTimer = model.get("private_topic_timer");
if (!privateTopicTimer) {
model.set("private_topic_timer", {});
}
showModal("edit-topic-timer", { model }); showModal("edit-topic-timer", { model });
this.controllerFor("modal").set("modalClass", "edit-topic-timer-modal"); this.controllerFor("modal").set("modalClass", "edit-topic-timer-modal");
}, },

View File

@ -1,20 +1,8 @@
{{#d-modal-body title="topic.topic_status_update.title" autoFocus="false"}} {{#d-modal-body title="topic.topic_status_update.title" autoFocus="false"}}
<div class="radios">
<label for="public-topic-timer">
{{radio-button id="public-topic-timer" name="topic-timer" value="true" selection=isPublic}}
<b>{{i18n "topic.topic_status_update.public_timer_types"}}</b>
</label>
<label for="private-topic-timer">
{{radio-button id="private-topic-timer" name="topic-timer" value="false" selection=isPublic}}
<b>{{i18n "topic.topic_status_update.private_timer_types"}}</b>
</label>
</div>
{{edit-topic-timer-form {{edit-topic-timer-form
topic=model topic=model
topicTimer=topicTimer topicTimer=topicTimer
timerTypes=selections timerTypes=publicTimerTypes
updateTime=updateTime updateTime=updateTime
onChangeStatusType=(action "onChangeStatusType") onChangeStatusType=(action "onChangeStatusType")
onChangeInput=(action "onChangeInput") onChangeInput=(action "onChangeInput")

View File

@ -286,15 +286,6 @@
</div> </div>
{{/if}} {{/if}}
{{#if model.private_topic_timer.execute_at}}
{{topic-timer-info
topicClosed=model.closed
statusType=model.private_topic_timer.status_type
executeAt=model.private_topic_timer.execute_at
duration=model.private_topic_timer.duration
removeTopicTimer=(action "removeTopicTimer" model.private_topic_timer.status_type "private_topic_timer")}}
{{/if}}
{{topic-timer-info {{topic-timer-info
topicClosed=model.closed topicClosed=model.closed
statusType=model.topic_timer.status_type statusType=model.topic_timer.status_type

View File

@ -4,6 +4,9 @@ module Jobs
class TopicReminder < ::Jobs::Base class TopicReminder < ::Jobs::Base
def execute(args) def execute(args)
# noop, TODO(martin 2021-03-11): Remove this after timers migrated and outstanding jobs cancelled
return
topic_timer = TopicTimer.find_by(id: args[:topic_timer_id]) topic_timer = TopicTimer.find_by(id: args[:topic_timer_id])
topic = topic_timer&.topic topic = topic_timer&.topic

View File

@ -554,7 +554,6 @@ class Topic < ActiveRecord::Base
def reload(options = nil) def reload(options = nil)
@post_numbers = nil @post_numbers = nil
@public_topic_timer = nil @public_topic_timer = nil
@private_topic_timer = nil
@is_category_topic = nil @is_category_topic = nil
super(options) super(options)
end end
@ -1309,10 +1308,6 @@ class Topic < ActiveRecord::Base
@public_topic_timer ||= topic_timers.find_by(deleted_at: nil, public_type: true) @public_topic_timer ||= topic_timers.find_by(deleted_at: nil, public_type: true)
end end
def private_topic_timer(user)
@private_topic_Timer ||= topic_timers.find_by(deleted_at: nil, public_type: false, user_id: user.id)
end
def delete_topic_timer(status_type, by_user: Discourse.system_user) def delete_topic_timer(status_type, by_user: Discourse.system_user)
options = { status_type: status_type } options = { status_type: status_type }
options.merge!(user: by_user) unless TopicTimer.public_types[status_type] options.merge!(user: by_user) unless TopicTimer.public_types[status_type]

View File

@ -156,7 +156,7 @@ class TopicTimer < ActiveRecord::Base
end end
def schedule_auto_reminder_job(time) def schedule_auto_reminder_job(time)
Jobs.enqueue_at(time, :topic_reminder, topic_timer_id: id) # noop, TODO(martin 2021-03-11): Remove this after timers migrated and outstanding jobs cancelled
end end
end end

View File

@ -64,7 +64,6 @@ class TopicViewSerializer < ApplicationSerializer
:bookmark_reminder_at, :bookmark_reminder_at,
:message_archived, :message_archived,
:topic_timer, :topic_timer,
:private_topic_timer,
:unicode_title, :unicode_title,
:message_bus_last_id, :message_bus_last_id,
:participant_count, :participant_count,
@ -202,15 +201,6 @@ class TopicViewSerializer < ApplicationSerializer
TopicTimerSerializer.new(object.topic.public_topic_timer, root: false) TopicTimerSerializer.new(object.topic.public_topic_timer, root: false)
end end
def include_private_topic_timer?
scope.user
end
def private_topic_timer
timer = object.topic.private_topic_timer(scope.user)
TopicTimerSerializer.new(timer, root: false)
end
def include_featured_link? def include_featured_link?
SiteSetting.topic_featured_link_enabled SiteSetting.topic_featured_link_enabled
end end

View File

@ -18,7 +18,6 @@ class WebHookTopicViewSerializer < TopicViewSerializer
current_post_number current_post_number
chunk_size chunk_size
topic_timer topic_timer
private_topic_timer
details details
image_url image_url
}.each do |attr| }.each do |attr|

View File

@ -2312,8 +2312,6 @@ en:
remove: "Remove Timer" remove: "Remove Timer"
publish_to: "Publish To:" publish_to: "Publish To:"
when: "When:" when: "When:"
public_timer_types: Topic Timers
private_timer_types: User Topic Timers
time_frame_required: Please select a time frame time_frame_required: Please select a time frame
auto_update_input: auto_update_input:
none: "Select a timeframe" none: "Select a timeframe"

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
class MigrateUserTopicTimersToBookmarkReminders < ActiveRecord::Migration[6.0]
def up
topic_timers_to_migrate = DB.query(<<~SQL, reminder_type: 5)
SELECT topic_timers.id, topic_timers.topic_id, topic_timers.user_id, execute_at,
posts.id AS first_post_id
FROM topic_timers
INNER JOIN topics ON topics.id = topic_timers.topic_id
INNER JOIN posts ON posts.topic_id = topics.id AND posts.post_number = 1
WHERE topics.deleted_at IS NULL
AND topic_timers.deleted_at IS NULL
AND topic_timers.status_type = :reminder_type
SQL
return if topic_timers_to_migrate.empty?
topic_timer_tuples = topic_timers_to_migrate.map do |tt|
"(#{tt.user_id}, #{tt.topic_id})"
end.join(", ")
existing_bookmarks = DB.query(<<~SQL)
SELECT bookmarks.id, reminder_at, reminder_type,
bookmarks.topic_id, post_id, bookmarks.user_id, posts.post_number
FROM bookmarks
INNER JOIN posts ON posts.id = bookmarks.post_id
WHERE (bookmarks.user_id, bookmarks.topic_id) IN (#{topic_timer_tuples})
SQL
new_bookmarks = []
topic_timers_to_migrate.each do |tt|
bookmark = existing_bookmarks.find do |bm|
# we only care about existing topic-level bookmarks here
# because topic timers are (funnily enough) topic-level
bm.topic_id == tt.topic_id && bm.user_id == tt.user_id && bm.post_number == 1
end
if !bookmark
# create one
now = Time.zone.now
new_bookmarks << "(#{tt.user_id}, #{tt.topic_id}, #{tt.first_post_id}, '#{tt.execute_at}', 6, '#{now}', '#{now}')"
else
if !bookmark.reminder_at
DB.exec(
"UPDATE bookmarks SET reminder_at = :reminder_at, reminder_type = 6 WHERE id = :bookmark_id",
reminder_at: tt.execute_at,
bookmark_id: bookmark.id
)
end
# if there is a bookmark with reminder already do nothing,
# we do not need to migrate the topic timer because it would
# cause a conflict
end
end
DB.exec(<<~SQL)
INSERT INTO bookmarks(user_id, topic_id, post_id, reminder_at, reminder_type, created_at, updated_at)
VALUES #{new_bookmarks.join(",\n")}
ON CONFLICT DO NOTHING
SQL
# TODO(2021-01-07): delete leftover trashed records
# trash these so the records are kept around for any possible data issues,
# they can be deleted in a few months
topic_timers_to_migrate_ids = topic_timers_to_migrate.map(&:id)
DB.exec(
"UPDATE topic_timers SET deleted_at = :deleted_at, deleted_by_id = :deleted_by WHERE ID IN (:ids)",
ids: topic_timers_to_migrate_ids,
deleted_at: Time.zone.now,
deleted_by: Discourse.system_user
)
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -202,7 +202,6 @@ acceptance("Poll quote", {
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 1, message_bus_last_id: 1,
participant_count: 1, participant_count: 1,
queued_posts_count: 0, queued_posts_count: 0,
@ -627,7 +626,6 @@ acceptance("Poll quote", {
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 2, message_bus_last_id: 2,
participant_count: 1, participant_count: 1,
queued_posts_count: 0, queued_posts_count: 0,

View File

@ -1,59 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Jobs::TopicReminder do
fab!(:admin) { Fabricate(:admin) }
fab!(:topic) do
Fabricate(:topic_timer,
user: admin,
status_type: TopicTimer.types[:reminder]
).topic
end
it "should be able to create a reminder" do
topic_timer = topic.topic_timers.first
freeze_time 1.day.from_now
expect {
described_class.new.execute(topic_timer_id: topic_timer.id)
}.to change { Notification.count }.by(1)
expect(admin.notifications.where(notification_type: Notification.types[:topic_reminder]).first&.topic_id).to eq(topic.id)
expect(TopicTimer.where(id: topic_timer.id).first).to be_nil
end
it "does nothing if it was trashed before the scheduled time" do
topic_timer = topic.topic_timers.first
topic_timer.trash!(Discourse.system_user)
freeze_time(1.day.from_now)
expect {
described_class.new.execute(topic_timer_id: topic_timer.id)
}.to_not change { Notification.count }
end
it "does nothing if job runs too early" do
topic_timer = topic.topic_timers.first
topic_timer.update_attribute(:execute_at, 8.hours.from_now)
freeze_time(6.hours.from_now)
expect {
described_class.new.execute(topic_timer_id: topic_timer.id)
}.to_not change { Notification.count }
end
it "does nothing if topic was deleted" do
topic_timer = topic.topic_timers.first
topic.trash!
freeze_time(1.day.from_now)
expect {
described_class.new.execute(topic_timer_id: topic_timer.id)
}.to_not change { Notification.count }
end
end

View File

@ -1711,20 +1711,6 @@ describe Topic do
end end
end end
describe '#private_topic_timer' do
let(:topic_timer) do
Fabricate(:topic_timer,
public_type: false,
user: user,
status_type: TopicTimer.private_types[:reminder]
)
end
it 'should return the right record' do
expect(topic_timer.topic.private_topic_timer(user)).to eq(topic_timer)
end
end
describe '#set_or_create_timer' do describe '#set_or_create_timer' do
let(:topic) { Fabricate.build(:topic) } let(:topic) { Fabricate.build(:topic) }
@ -1875,38 +1861,6 @@ describe Topic do
expect(topic.reload.closed).to eq(true) expect(topic.reload.closed).to eq(true)
end end
end end
describe "private status type" do
fab!(:topic) { Fabricate(:topic) }
let(:reminder) { Fabricate(:topic_timer, user: admin, topic: topic, status_type: TopicTimer.types[:reminder]) }
fab!(:other_admin) { Fabricate(:admin) }
it "lets two users have their own record" do
reminder
expect {
topic.set_or_create_timer(TopicTimer.types[:reminder], 2, by_user: other_admin)
}.to change { TopicTimer.count }.by(1)
end
it 'should not be override when setting a public topic timer' do
reminder
expect do
topic.set_or_create_timer(TopicTimer.types[:close], 3, by_user: reminder.user)
end.to change { TopicTimer.count }.by(1)
end
it "can update a user's existing record" do
freeze_time now
reminder
expect {
topic.set_or_create_timer(TopicTimer.types[:reminder], 11, by_user: admin)
}.to_not change { TopicTimer.count }
reminder.reload
expect(reminder.execute_at).to eq_time(11.hours.from_now)
end
end
end end
describe '.for_digest' do describe '.for_digest' do

View File

@ -18,23 +18,6 @@ RSpec.describe TopicTimer, type: :model do
expect { Fabricate(:topic_timer, topic: topic) } expect { Fabricate(:topic_timer, topic: topic) }
.to raise_error(ActiveRecord::RecordInvalid) .to raise_error(ActiveRecord::RecordInvalid)
end end
it 'should ensure that only one active private topic timer exists per user' do
Fabricate(:topic_timer, topic: topic, user: admin, status_type: TopicTimer.types[:reminder])
expect { Fabricate(:topic_timer, topic: topic, user: admin, status_type: TopicTimer.types[:reminder]) }
.to raise_error(ActiveRecord::RecordInvalid)
end
it 'should allow users to have their own private topic timer' do
expect do
Fabricate(:topic_timer,
topic: topic,
user: Fabricate(:admin),
status_type: TopicTimer.types[:reminder]
)
end.to_not raise_error
end
end end
describe '#execute_at' do describe '#execute_at' do
@ -274,23 +257,5 @@ RSpec.describe TopicTimer, type: :model do
state: false state: false
})).to eq(true) })).to eq(true)
end end
it "should enqueue remind me jobs that have been missed" do
reminder = Fabricate(:topic_timer,
status_type: described_class.types[:reminder],
execute_at: Time.zone.now - 1.hour,
created_at: Time.zone.now - 2.hour
)
# creating topic timers already enqueues jobs
# let's delete them to test ensure_consistency!
Sidekiq::Worker.clear_all
expect { described_class.ensure_consistency! }
.to change { Jobs::TopicReminder.jobs.count }.by(1)
job_args = Jobs::TopicReminder.jobs.first["args"].first
expect(job_args["topic_timer_id"]).to eq(reminder.id)
end
end end
end end

View File

@ -309,7 +309,6 @@ describe 'posts' do
chunk_size: { type: :integer }, chunk_size: { type: :integer },
bookmarked: { type: :boolean }, bookmarked: { type: :boolean },
topic_timer: { type: :string, nullable: true }, topic_timer: { type: :string, nullable: true },
private_topic_timer: { type: :string, nullable: true },
message_bus_last_id: { type: :integer }, message_bus_last_id: { type: :integer },
participant_count: { type: :integer }, participant_count: { type: :integer },
show_read_indicator: { type: :boolean }, show_read_indicator: { type: :boolean },

View File

@ -295,7 +295,6 @@ QUnit.test("reply_as_new_group_message", async (assert) => {
bookmarked: false, bookmarked: false,
message_archived: false, message_archived: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 5, message_bus_last_id: 5,
participant_count: 1, participant_count: 1,
pm_with_non_human_user: false, pm_with_non_human_user: false,

View File

@ -21,7 +21,6 @@ acceptance("Topic - Edit timer", {
QUnit.test("default", async (assert) => { QUnit.test("default", async (assert) => {
updateCurrentUser({ moderator: true }); updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
const futureDateInputSelector = selectKit(".future-date-input-selector"); const futureDateInputSelector = selectKit(".future-date-input-selector");
await visit("/t/internationalization-localization"); await visit("/t/internationalization-localization");
@ -30,14 +29,6 @@ QUnit.test("default", async (assert) => {
assert.equal(futureDateInputSelector.header().label(), "Select a timeframe"); assert.equal(futureDateInputSelector.header().label(), "Select a timeframe");
assert.equal(futureDateInputSelector.header().value(), null); assert.equal(futureDateInputSelector.header().value(), null);
await click("#private-topic-timer");
assert.equal(timerType.header().label(), "Remind Me");
assert.equal(timerType.header().value(), "reminder");
assert.equal(futureDateInputSelector.header().label(), "Select a timeframe");
assert.equal(futureDateInputSelector.header().value(), null);
}); });
QUnit.test("autoclose - specific time", async (assert) => { QUnit.test("autoclose - specific time", async (assert) => {

View File

@ -691,7 +691,6 @@ export default {
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 1, message_bus_last_id: 1,
participant_count: 1, participant_count: 1,
show_read_indicator: false, show_read_indicator: false,

View File

@ -5071,7 +5071,6 @@ export default {
bookmarked: false, bookmarked: false,
message_archived: false, message_archived: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 7, message_bus_last_id: 7,
participant_count: 2, participant_count: 2,
pm_with_non_human_user: false pm_with_non_human_user: false
@ -5352,7 +5351,6 @@ export default {
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
topic_timer: null, topic_timer: null,
private_topic_timer: null,
message_bus_last_id: 4, message_bus_last_id: 4,
participant_count: 2, participant_count: 2,
show_read_indicator: false, show_read_indicator: false,