mirror of
https://github.com/discourse/discourse.git
synced 2026-08-10 04:58:31 -05:00
FEATURE: Create notification schedule to automatically set do not disturb time (#11665)
This adds a new table UserNotificationSchedules which stores monday-friday start and ends times that each user would like to receive notifications (with a Boolean enabled to remove the use of the schedule). There is then a background job that runs every day and creates do_not_disturb_timings for each user with an enabled notification schedule. The job schedules timings 2 days in advance. The job is designed so that it can be run at any point in time, and it will not create duplicate records. When a users saves their notification schedule, the schedule processing service will run and schedule do_not_disturb_timings. If the user should be in DND due to their schedule, the user will immediately be put in DND (message bus publishes this state). The UI for a user's notification schedule is in user -> preferences -> notifications. By default every day is 8am - 5pm when first enabled.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe UserNotificationSchedule do
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
|
||||
describe "validations" do
|
||||
it 'is invalid when no times are specified' do
|
||||
schedule = UserNotificationSchedule.create({
|
||||
user: user,
|
||||
enabled: true
|
||||
})
|
||||
expect(schedule.errors.keys).to eq([
|
||||
:day_0_start_time,
|
||||
:day_0_end_time,
|
||||
:day_1_start_time,
|
||||
:day_1_end_time,
|
||||
:day_2_start_time,
|
||||
:day_2_end_time,
|
||||
:day_3_start_time,
|
||||
:day_3_end_time,
|
||||
:day_4_start_time,
|
||||
:day_4_end_time,
|
||||
:day_5_start_time,
|
||||
:day_5_end_time,
|
||||
:day_6_start_time,
|
||||
:day_6_end_time,
|
||||
])
|
||||
end
|
||||
|
||||
it 'is invalid when a start time is below -1' do
|
||||
schedule = UserNotificationSchedule.new({
|
||||
user: user,
|
||||
}.merge(UserNotificationSchedule::DEFAULT))
|
||||
schedule.day_0_start_time = -2
|
||||
schedule.save
|
||||
expect(schedule.errors.count).to eq(1)
|
||||
expect(schedule.errors[:day_0_start_time]).to be_present
|
||||
end
|
||||
|
||||
it 'invalid when an end time is greater than 1440' do
|
||||
schedule = UserNotificationSchedule.new({
|
||||
user: user,
|
||||
}.merge(UserNotificationSchedule::DEFAULT))
|
||||
schedule.day_0_end_time = 1441
|
||||
schedule.save
|
||||
expect(schedule.errors.count).to eq(1)
|
||||
expect(schedule.errors[:day_0_end_time]).to be_present
|
||||
end
|
||||
|
||||
it 'invalid when the start time is greater than the end time' do
|
||||
schedule = UserNotificationSchedule.new({
|
||||
user: user,
|
||||
}.merge(UserNotificationSchedule::DEFAULT))
|
||||
schedule.day_0_start_time = 1000
|
||||
schedule.day_0_end_time = 800
|
||||
schedule.save
|
||||
expect(schedule.errors.count).to eq(1)
|
||||
expect(schedule.errors[:day_0_start_time]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1955,6 +1955,38 @@ describe UsersController do
|
||||
end
|
||||
end
|
||||
|
||||
context "with user_notification_schedule attributes" do
|
||||
it "updates the user's notification schedule" do
|
||||
params = {
|
||||
user_notification_schedule: {
|
||||
enabled: true,
|
||||
day_0_start_time: 30,
|
||||
day_0_end_time: 60,
|
||||
day_1_start_time: 30,
|
||||
day_1_end_time: 60,
|
||||
day_2_start_time: 30,
|
||||
day_2_end_time: 60,
|
||||
day_3_start_time: 30,
|
||||
day_3_end_time: 60,
|
||||
day_4_start_time: 30,
|
||||
day_4_end_time: 60,
|
||||
day_5_start_time: 30,
|
||||
day_5_end_time: 60,
|
||||
day_6_start_time: 30,
|
||||
day_6_end_time: 60,
|
||||
}
|
||||
}
|
||||
put "/u/#{user.username}.json", params: params
|
||||
|
||||
user.reload
|
||||
expect(user.user_notification_schedule.enabled).to eq(true)
|
||||
expect(user.user_notification_schedule.day_0_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_0_end_time).to eq(60)
|
||||
expect(user.user_notification_schedule.day_6_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_6_end_time).to eq(60)
|
||||
end
|
||||
end
|
||||
|
||||
context "uneditable field" do
|
||||
let!(:user_field) { Fabricate(:user_field, editable: false) }
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ RSpec.describe WebHookUserSerializer do
|
||||
|
||||
it 'should only include the required keys' do
|
||||
count = serializer.as_json.keys.count
|
||||
difference = count - 50
|
||||
difference = count - 51
|
||||
|
||||
expect(difference).to eq(0), lambda {
|
||||
message = (difference < 0 ?
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe UserNotificationScheduleProcessor do
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
let(:standard_schedule) {
|
||||
schedule = UserNotificationSchedule.create({
|
||||
user: user
|
||||
}.merge(UserNotificationSchedule::DEFAULT))
|
||||
schedule.enabled = true
|
||||
schedule.save
|
||||
schedule
|
||||
}
|
||||
|
||||
describe "#create_do_not_disturb_timings" do
|
||||
[
|
||||
{ timezone: "UTC", offset: "+00:00" },
|
||||
{ timezone: "America/Chicago", offset: "-06:00" },
|
||||
{ timezone: "Australia/Sydney", offset: "+11:00" },
|
||||
].each do |timezone_info|
|
||||
it 'creates dnd timings correctly for each timezone' do
|
||||
user.user_option.update(timezone: timezone_info[:timezone])
|
||||
|
||||
travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
|
||||
# The default schedule is 8am - 5pm.
|
||||
# Expext DND timings to fill gaps before/after those times for 3 days.
|
||||
dnd_timings = user.do_not_disturb_timings
|
||||
offset = timezone_info[:offset]
|
||||
expect(dnd_timings[0].starts_at).to eq_time(Time.new(2020, 1, 4, 0, 0, 0, offset))
|
||||
expect(dnd_timings[0].ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, offset))
|
||||
|
||||
expect(dnd_timings[1].starts_at).to eq_time(Time.new(2020, 1, 4, 17, 0, 0, offset))
|
||||
expect(dnd_timings[1].ends_at).to eq_time(Time.new(2020, 1, 5, 7, 59, 0, offset))
|
||||
|
||||
expect(dnd_timings[2].starts_at).to eq_time(Time.new(2020, 1, 5, 17, 0, 0, offset))
|
||||
expect(dnd_timings[2].ends_at).to eq_time(Time.new(2020, 1, 6, 7, 59, 0, offset))
|
||||
|
||||
expect(dnd_timings[3].starts_at).to eq_time(Time.new(2020, 1, 6, 17, 0, 0, offset))
|
||||
expect(dnd_timings[3].ends_at).to be_within(1.second).of Time.new(2020, 1, 6, 23, 59, 59, offset)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not create duplicate record, but ensures the correct records exist' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
|
||||
travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
expect(user.do_not_disturb_timings.count).to eq(4)
|
||||
# All duplicates, so no new timings should be created
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
expect(user.do_not_disturb_timings.count).to eq(4)
|
||||
end
|
||||
|
||||
travel_to Time.new(2020, 1, 5, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
# There is 1 overlap, so expect only 3 more to be created
|
||||
expect(user.do_not_disturb_timings.count).to eq(7)
|
||||
end
|
||||
|
||||
travel_to Time.new(2020, 1, 10, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
# There is no overlap, so expect only 4 more to be created
|
||||
expect(user.do_not_disturb_timings.count).to eq(11)
|
||||
end
|
||||
end
|
||||
|
||||
it 'extends previously scheduled dnd timings to remove gaps' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
|
||||
travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
|
||||
existing_timing = user.do_not_disturb_timings.create(
|
||||
scheduled: true,
|
||||
starts_at: 1.day.ago,
|
||||
ends_at: Time.new(2020, 1, 03, 11, 0, 0, "+00:00").end_of_day
|
||||
)
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
|
||||
expect(existing_timing.reload.ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, "+00:00"))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the correct timings when the whole schedule is DND (-1)' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
schedule = standard_schedule
|
||||
schedule.update(
|
||||
day_0_start_time: -1,
|
||||
day_1_start_time: -1,
|
||||
day_2_start_time: -1,
|
||||
day_3_start_time: -1,
|
||||
day_4_start_time: -1,
|
||||
day_5_start_time: -1,
|
||||
day_6_start_time: -1,
|
||||
)
|
||||
|
||||
travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
|
||||
expect(user.do_not_disturb_timings.count).to eq(1)
|
||||
expect(user.do_not_disturb_timings.first.starts_at).to eq_time(Time.new(2020, 1, 4, 0, 0, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings.first.ends_at).to be_within(1.second).of Time.new(2020, 1, 6, 23, 59, 59, "+00:00")
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the correct timings at the end of a month and year' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
schedule = standard_schedule
|
||||
schedule.update(
|
||||
day_3_start_time: -1, # December 31, 2020 was a thursday. testing more cases.
|
||||
)
|
||||
|
||||
travel_to Time.new(2020, 12, 31, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
|
||||
expect(user.do_not_disturb_timings[0].starts_at).to eq_time(Time.new(2020, 12, 31, 0, 0, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings[0].ends_at).to eq_time(Time.new(2021, 1, 1, 7, 59, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings[1].starts_at).to eq_time(Time.new(2021, 1, 1, 17, 0, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings[1].ends_at).to eq_time(Time.new(2021, 1, 2, 7, 59, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings[2].starts_at).to eq_time(Time.new(2021, 1, 2, 17, 0, 0, "+00:00"))
|
||||
expect(user.do_not_disturb_timings[2].ends_at).to be_within(1.second).of Time.new(2021, 1, 2, 23, 59, 59, "+00:00")
|
||||
end
|
||||
end
|
||||
|
||||
it 'handles midnight to midnight for multiple days (no timings created)' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
schedule = standard_schedule
|
||||
schedule.update(
|
||||
day_0_start_time: 0,
|
||||
day_0_end_time: 1440,
|
||||
day_1_start_time: 0,
|
||||
day_1_end_time: 1440,
|
||||
day_2_start_time: 0,
|
||||
day_2_end_time: 1440,
|
||||
)
|
||||
travel_to Time.new(2021, 1, 4, 12, 0, 0, "+00:00") do
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
|
||||
expect(user.do_not_disturb_timings.count).to eq(0)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
it 'publishes to message bus when the user should enter DND' do
|
||||
user.user_option.update(timezone: "UTC")
|
||||
schedule = standard_schedule
|
||||
travel_to Time.new(2020, 12, 31, 1, 0, 0, "+00:00") do
|
||||
MessageBus.expects(:publish).with(
|
||||
"/do-not-disturb/#{user.id}",
|
||||
{ ends_at: Time.new(2020, 12, 31, 7, 59, 0, "+00:00").httpdate },
|
||||
user_ids: [user.id]
|
||||
)
|
||||
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -194,6 +194,65 @@ describe UserUpdater do
|
||||
expect(user.user_option.theme_ids).to eq([theme.id, child.id])
|
||||
end
|
||||
|
||||
let(:schedule_attrs) {
|
||||
{
|
||||
enabled: true,
|
||||
day_0_start_time: 30,
|
||||
day_0_end_time: 60,
|
||||
day_1_start_time: 30,
|
||||
day_1_end_time: 60,
|
||||
day_2_start_time: 30,
|
||||
day_2_end_time: 60,
|
||||
day_3_start_time: 30,
|
||||
day_3_end_time: 60,
|
||||
day_4_start_time: 30,
|
||||
day_4_end_time: 60,
|
||||
day_5_start_time: 30,
|
||||
day_5_end_time: 60,
|
||||
day_6_start_time: 30,
|
||||
day_6_end_time: 60,
|
||||
}
|
||||
}
|
||||
|
||||
context 'with user_notification_schedule' do
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
|
||||
it "allows users to create their notification schedule when it doesn't exist previously" do
|
||||
expect(user.user_notification_schedule).to be_nil
|
||||
updater = UserUpdater.new(acting_user, user)
|
||||
|
||||
updater.update(user_notification_schedule: schedule_attrs)
|
||||
user.reload
|
||||
expect(user.user_notification_schedule.enabled).to eq(true)
|
||||
expect(user.user_notification_schedule.day_0_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_0_end_time).to eq(60)
|
||||
expect(user.user_notification_schedule.day_6_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_6_end_time).to eq(60)
|
||||
end
|
||||
|
||||
it "allows users to update their notification schedule" do
|
||||
UserNotificationSchedule.create({
|
||||
user: user,
|
||||
}.merge(UserNotificationSchedule::DEFAULT))
|
||||
updater = UserUpdater.new(acting_user, user)
|
||||
updater.update(user_notification_schedule: schedule_attrs)
|
||||
user.reload
|
||||
expect(user.user_notification_schedule.enabled).to eq(true)
|
||||
expect(user.user_notification_schedule.day_0_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_0_end_time).to eq(60)
|
||||
expect(user.user_notification_schedule.day_6_start_time).to eq(30)
|
||||
expect(user.user_notification_schedule.day_6_end_time).to eq(60)
|
||||
end
|
||||
|
||||
it "processes the schedule and do_not_disturb_timings are created" do
|
||||
updater = UserUpdater.new(acting_user, user)
|
||||
|
||||
expect {
|
||||
updater.update(user_notification_schedule: schedule_attrs)
|
||||
}.to change { user.do_not_disturb_timings.count }.by(4)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sso overrides bio' do
|
||||
it 'does not change bio' do
|
||||
SiteSetting.sso_url = "https://www.example.com/sso"
|
||||
|
||||
Reference in New Issue
Block a user