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:
Mark VanLandingham 2021-01-20 10:31:52 -06:00 committed by GitHub
parent 54a01701d7
commit 1a7922bea2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 892 additions and 27 deletions

View File

@ -0,0 +1,7 @@
import Component from "@ember/component";
import { i18n } from "discourse/lib/computed";
export default Component.extend({
tagName: "",
dayLabel: i18n("day", "user.notification_schedule.%@"),
});

View File

@ -0,0 +1,139 @@
import EmberObject, { action } from "@ember/object";
import Component from "@ember/component";
import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators";
const DAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
const Day = EmberObject.extend({
id: null,
startTimeOptions: null,
model: null,
@action
onChangeStartTime(val) {
this.startingTimeChangedForDay(val);
},
@action
onChangeEndTime(val) {
this.set(`model.user_notification_schedule.day_${this.id}_end_time`, val);
},
@discourseComputed(
"model.user_notification_schedule.day_{0,1,2,3,4,5,6}_start_time"
)
startTimeValue(schedule) {
return schedule[`day_${this.id}_start_time`];
},
@discourseComputed(
"model.user_notification_schedule.day_{0,1,2,3,4,5,6}_start_time"
)
endTimeOptions(schedule) {
return this.buildEndTimeOptionsFor(schedule[`day_${this.id}_start_time`]);
},
@discourseComputed(
"model.user_notification_schedule.day_{0,1,2,3,4,5,6}_end_time"
)
endTimeValue(schedule) {
return schedule[`day_${this.id}_end_time`];
},
startingTimeChangedForDay(val) {
val = parseInt(val, 10);
this.model.set(`user_notification_schedule.day_${this.id}_start_time`, val);
if (
val !== "-1" &&
this.model.user_notification_schedule[`day_${this.id}_end_time`] <= val
) {
this.model.set(
`user_notification_schedule.day_${this.id}_end_time`,
val + 30
);
}
},
buildEndTimeOptionsFor(startTime) {
startTime = parseInt(startTime, 10);
if (startTime === -1) {
return null;
}
return this.buildTimeOptions(startTime + 30, {
includeNone: false,
showMidnight: true,
});
},
});
export default Component.extend({
days: null,
didInsertElement() {
this._super(...arguments);
this.set(
"startTimeOptions",
this.buildTimeOptions(0, {
includeNone: true,
showMidnight: false,
})
);
this.set("days", []);
DAYS.forEach((day, index) => {
this.days.pushObject(
Day.create({
id: index,
day,
model: this.model,
buildTimeOptions: this.buildTimeOptions,
startTimeOptions: this.startTimeOptions,
})
);
});
},
buildTimeOptions(startAt, opts = { includeNone: false, showMidnight: true }) {
let timeOptions = [];
if (opts.includeNone) {
timeOptions.push({
name: I18n.t("user.notification_schedule.none"),
value: -1,
});
}
for (let timeInMin = startAt; timeInMin <= 1440; timeInMin += 30) {
let hours = Math.floor(timeInMin / 60);
let minutes = timeInMin % 60;
if (minutes === 0) {
minutes = "00";
}
if (hours === 24) {
if (opts.showMidnight) {
timeOptions.push({
name: I18n.t("user.notification_schedule.midnight"),
value: 1440,
});
}
break;
}
timeOptions.push({
name: moment().set("hour", hours).set("minute", minutes).format("LT"),
value: timeInMin,
});
}
return timeOptions;
},
});

View File

@ -1,26 +1,18 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
export default Controller.extend(ModalFunctionality, {
duration: null,
saving: false,
@discourseComputed("saving", "duration")
saveDisabled(saving, duration) {
return saving || !duration;
},
@action
setDuration(duration) {
this.set("duration", duration);
this.save();
},
@action
save() {
this.set("saving", true);
this.currentUser
.enterDoNotDisturbFor(this.duration)
.then(() => {
@ -28,9 +20,12 @@ export default Controller.extend(ModalFunctionality, {
})
.catch((e) => {
this.flash(extractError(e), "error");
})
.finally(() => {
this.set("saving", false);
});
},
@action
navigateToNotificationSchedule() {
this.transitionToRoute("preferences.notifications", this.currentUser);
this.send("closeModal");
},
});

View File

@ -15,6 +15,7 @@ export default Controller.extend({
"like_notification_frequency",
"allow_private_messages",
"enable_allowed_pm_users",
"user_notification_schedule",
];
this.likeNotificationFrequencies = [

View File

@ -59,6 +59,7 @@ let userFields = [
"watching_first_post_tags",
"date_of_birth",
"primary_group_id",
"user_notification_schedule",
];
export function addSaveableUserField(fieldName) {

View File

@ -0,0 +1,22 @@
<tr class="day {{dayLabel}}">
<td class="day-label">{{dayLabel}}</td>
<td class="starts-at">
{{combo-box
valueProperty="value"
content=startTimeOptions
value=startTimeValue
onChange=onChangeStartTime
}}
</td>
{{#if endTimeOptions}}
<td class="to">{{i18n "user.notification_schedule.to"}}</td>
<td class="ends-at">
{{combo-box
valueProperty="value"
content=endTimeOptions
value=endTimeValue
onChange=onChangeEndTime
}}
</td>
{{/if}}
</tr>

View File

@ -0,0 +1,26 @@
<div class="control-group notification-schedule">
<label class="control-label">{{i18n "user.notification_schedule.title"}}</label>
{{preference-checkbox
labelKey="user.notification_schedule.label"
checked=model.user_notification_schedule.enabled}}
{{#if model.user_notification_schedule.enabled}}
<div class="instruction">{{i18n "user.notification_schedule.tip"}}</div>
<table class="notification-schedule-table">
<tbody class="notification-schedule-tbody">
{{#each days as |day|}}
{{user-notification-schedule-day
day=day.day
startTimeOptions=day.startTimeOptions
startTimeValue=day.startTimeValue
onChangeStartTime=day.onChangeStartTime
endTimeOptions=day.endTimeOptions
endTimeValue=day.endTimeValue
onChangeEndTime=day.onChangeEndTime
}}
{{/each}}
</tbody>
</table>
{{/if}}
</div>

View File

@ -13,12 +13,8 @@
{{i18n "do_not_disturb.options.tomorrow"}}
{{/tap-tile}}
{{/tap-tile-grid}}
<a href {{ action navigateToNotificationSchedule }}>
{{i18n "do_not_disturb.set_schedule"}}
</a>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
label="do_not_disturb.save"
action=(action "save")
class="btn-primary"
disabled=saveDisabled
}}
</div>

View File

@ -52,6 +52,8 @@
</div>
{{/unless}}
{{user-notification-schedule model=model}}
{{#if siteSettings.enable_personal_messages}}
<div class="control-group private-messages">
<label class="control-label">{{i18n "user.private_messages"}}</label>

View File

@ -35,7 +35,6 @@ acceptance("Do not disturb", function (needs) {
assert.ok(tiles.length === 4, "There are 4 duration choices");
await click(tiles[0]);
await click(".modal-footer .btn.btn-primary");
assert.ok(
queryAll(".do-not-disturb-modal")[0].style.display === "none",

View File

@ -0,0 +1,108 @@
import {
acceptance,
exists,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
acceptance("User notification schedule", function (needs) {
needs.user();
test("the schedule interface is hidden until enabled", async function (assert) {
await visit("/u/eviltrout/preferences/notifications");
assert.ok(
!exists(".notification-schedule-table"),
"notification schedule is hidden"
);
await click(".control-group.notification-schedule input");
assert.ok(
exists(".notification-schedule-table"),
"notification schedule is visible"
);
});
test("By default every day is selected 8:00am - 5:00pm", async function (assert) {
await visit("/u/eviltrout/preferences/notifications");
await click(".control-group.notification-schedule input");
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
].forEach((day) => {
assert.equal(
selectKit(`.day.${day} .starts-at .combobox`).header().label(),
"8:00 AM",
"8am is selected"
);
assert.equal(
selectKit(`.day.${day} .starts-at .combobox`).header().value(),
"480",
"8am is 480"
);
assert.equal(
selectKit(`.day.${day} .ends-at .combobox`).header().label(),
"5:00 PM",
"5am is selected"
);
assert.equal(
selectKit(`.day.${day} .ends-at .combobox`).header().value(),
"1020",
"5pm is 1020"
);
});
});
test("If 'none' is selected for the start time, end time dropdown is removed", async function (assert) {
await visit("/u/eviltrout/preferences/notifications");
await click(".control-group.notification-schedule input");
await selectKit(".day.Monday .combobox").expand();
await selectKit(".day.Monday .combobox").selectRowByValue(-1);
assert.equal(
selectKit(".day.Monday .starts-at .combobox").header().value(),
"-1",
"set monday input to none"
);
assert.equal(
selectKit(".day.Monday .starts-at .combobox").header().label(),
"None",
"set monday label to none"
);
assert.equal(
queryAll(".day.Monday .select-kit.single-select").length,
1,
"The end time input is hidden"
);
});
test("If start time is after end time, end time gets bumped 30 minutes past start time", async function (assert) {
await visit("/u/eviltrout/preferences/notifications");
await click(".control-group.notification-schedule input");
await selectKit(".day.Tuesday .starts-at .combobox").expand();
await selectKit(".day.Tuesday .starts-at .combobox").selectRowByValue(
"1350"
);
assert.equal(
selectKit(".day.Tuesday .ends-at .combobox").header().value(),
"1380",
"End time is 30 past start time"
);
await selectKit(".day.Tuesday .ends-at .combobox").expand();
assert.ok(
!selectKit(".day.Tuesday .ends-at .combobox").rowByValue(1350).exists(),
"End time options are limited to + 30 past start time"
);
});
});

View File

@ -276,7 +276,24 @@ export default {
seen_at: "2018-09-08T21:44:42.209Z",
is_active: false
}
]
],
user_notification_schedule: {
enabled: false,
day_0_start_time: 480,
day_0_end_time: 1020,
day_1_start_time: 480,
day_1_end_time: 1020,
day_2_start_time: 480,
day_2_end_time: 1020,
day_3_start_time: 480,
day_3_end_time: 1020,
day_4_start_time: 480,
day_4_end_time: 1020,
day_5_start_time: 480,
day_5_end_time: 1020,
day_6_start_time: 480,
day_6_end_time: 1020,
}
}
},
"/u/eviltrout/card.json": {

View File

@ -789,3 +789,24 @@
grid-column: 1 / span 2;
}
}
.notification-schedule {
.instruction {
margin-top: 12px;
margin-bottom: 10px;
}
.notification-schedule-table {
.notification-schedule-tbody {
border-top-width: 1px;
.day {
.day-label {
padding: 1em 1em 1em 0;
}
.to {
padding: 0 0.5em;
}
}
}
}
}

View File

@ -1624,6 +1624,7 @@ class UsersController < ApplicationController
permitted.concat UserUpdater::OPTION_ATTR
permitted.concat UserUpdater::CATEGORY_IDS.keys.map { |k| { k => [] } }
permitted.concat UserUpdater::TAG_NAMES.keys
permitted << UserUpdater::NOTIFICATION_SCHEDULE_ATTRS
result = params
.permit(permitted, theme_ids: [])

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Jobs
class ProcessUserNotificationSchedules < ::Jobs::Scheduled
every 1.day
def execute(args)
UserNotificationSchedule.enabled.includes(:user).each do |schedule|
begin
schedule.create_do_not_disturb_timings
rescue
Rails.logger.warn("Failed to process user_notification_schedule with ID #{schedule.id}")
end
end
end
end
end

View File

@ -47,6 +47,7 @@ class User < ActiveRecord::Base
has_one :anonymous_user_master, class_name: 'AnonymousUser', dependent: :destroy
has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: 'AnonymousUser', dependent: :destroy
has_one :invited_user, dependent: :destroy
has_one :user_notification_schedule, dependent: :destroy
# delete all is faster but bypasses callbacks
has_many :bookmarks, dependent: :delete_all

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class UserNotificationSchedule < ActiveRecord::Base
belongs_to :user
DEFAULT = -> {
attrs = { enabled: false }
7.times do |n|
attrs["day_#{n}_start_time".to_sym] = 480
attrs["day_#{n}_end_time".to_sym] = 1020
end
attrs
}.call
validate :has_valid_times
validates :enabled, inclusion: { in: [ true, false ] }
scope :enabled, -> { where(enabled: true) }
def create_do_not_disturb_timings(delete_existing: false)
user.do_not_disturb_timings.where(scheduled: true).destroy_all if delete_existing
UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(self)
end
private
def has_valid_times
7.times do |n|
start_key = "day_#{n}_start_time"
end_key = "day_#{n}_end_time"
if self[start_key].nil? || self[start_key] > 1410 || self[start_key] < -1
errors.add(start_key, "is invalid")
end
if self[end_key].nil? || self[end_key] > 1440
errors.add(end_key, "is invalid")
end
if self[start_key] && self[end_key] && self[start_key] > self[end_key]
errors.add(start_key, "is after end time")
end
end
end
end

View File

@ -58,7 +58,8 @@ class UserSerializer < UserCardSerializer
:can_change_location,
:can_change_website,
:user_api_keys,
:user_auth_tokens
:user_auth_tokens,
:user_notification_schedule
untrusted_attributes :bio_raw,
:bio_cooked,
@ -67,6 +68,10 @@ class UserSerializer < UserCardSerializer
###
### ATTRIBUTES
###
#
def user_notification_schedule
object.user_notification_schedule || UserNotificationSchedule::DEFAULT
end
def mailing_list_posts_per_day
val = Post.estimate_posts_per_day

View File

@ -0,0 +1,87 @@
# frozen_string_literal: true
class UserNotificationScheduleProcessor
attr_accessor :schedule, :user, :timezone_name
def initialize(schedule)
@schedule = schedule
@user = schedule.user
@timezone_name = user.user_option.timezone
end
def create_do_not_disturb_timings
local_time = Time.now.in_time_zone(timezone_name)
create_timings_for(local_time, days: 2)
end
def self.create_do_not_disturb_timings_for(schedule)
processor = UserNotificationScheduleProcessor.new(schedule)
processor.create_do_not_disturb_timings
end
private
def create_timings_for(local_time, days: 0, previous_timing: nil)
weekday = transform_wday(local_time.wday)
start_minute = schedule["day_#{weekday}_start_time"]
end_minute = schedule["day_#{weekday}_end_time"]
previous_timing = find_previous_timing(local_time) if previous_timing.nil? && start_minute != 0
if start_minute > 0
previous_timing.ends_at = utc_time_at_minute(local_time, start_minute - 1)
if previous_timing.id
previous_timing.save
else
user.do_not_disturb_timings.find_or_create_by(previous_timing.attributes.except("id"))
end
next_timing = user.do_not_disturb_timings.new(
starts_at: utc_time_at_minute(local_time, end_minute),
scheduled: true
)
save_timing_and_continue(local_time, next_timing, days)
else
save_timing_and_continue(local_time, previous_timing, days)
end
end
private
def find_previous_timing(local_time)
# Try and find a previously scheduled dnd timing that we can extend if the
# ends_at is at the previous midnight. fallback to a new timing if not.
previous = user.do_not_disturb_timings.find_by(
ends_at: (local_time - 1.day).end_of_day.utc,
scheduled: true
)
previous || user.do_not_disturb_timings.new(
starts_at: local_time.beginning_of_day.utc,
scheduled: true
)
end
def save_timing_and_continue(local_time, timing, days)
if days == 0
if timing
timing.ends_at = local_time.end_of_day.utc
user.do_not_disturb_timings.find_or_create_by(timing.attributes.except("id"))
end
user.publish_do_not_disturb(ends_at: user.do_not_disturb_until)
else
create_timings_for(local_time + 1.day, days: days - 1, previous_timing: timing)
end
end
def utc_time_at_minute(base_time, total_minutes)
hour = total_minutes / 60
minute = total_minutes % 60
Time.new(base_time.year, base_time.month, base_time.day, hour, minute, 0, base_time.formatted_offset).utc
end
def transform_wday(wday)
wday == 0 ? 6 : wday - 1
end
end

View File

@ -49,6 +49,15 @@ class UserUpdater
:skip_new_user_tips
]
NOTIFICATION_SCHEDULE_ATTRS = -> {
attrs = [:enabled]
7.times do |n|
attrs.push("day_#{n}_start_time".to_sym)
attrs.push("day_#{n}_end_time".to_sym)
end
{ user_notification_schedule: attrs }
}.call
def initialize(actor, user)
@user = user
@guardian = Guardian.new(actor)
@ -82,6 +91,11 @@ class UserUpdater
user_profile.card_background_upload_id = upload.id
end
if attributes[:user_notification_schedule]
user_notification_schedule = user.user_notification_schedule || UserNotificationSchedule.new(user: user)
user_notification_schedule.assign_attributes(attributes[:user_notification_schedule])
end
old_user_name = user.name.present? ? user.name : ""
user.name = attributes.fetch(:name) { user.name }
@ -170,7 +184,7 @@ class UserUpdater
end
name_changed = user.name_changed?
if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) &&
if (saved = (!save_options || user.user_option.save) && (user_notification_schedule.nil? || user_notification_schedule.save) && user_profile.save && user.save) &&
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
StaffActionLogger.new(@actor).log_name_change(
@ -184,7 +198,13 @@ class UserUpdater
return saved
end
DiscourseEvent.trigger(:user_updated, user) if saved
if saved
if user_notification_schedule
user_notification_schedule.create_do_not_disturb_timings(delete_existing: true)
end
DiscourseEvent.trigger(:user_updated, user)
end
saved
end

View File

@ -907,6 +907,20 @@ en:
mute_option_title: "You will not receive any notifications related to this user."
normal_option: "Normal"
normal_option_title: "You will be notified if this user replies to you, quotes you, or mentions you."
notification_schedule:
title: "Notification Schedule"
label: "Enable custom notification schedule"
tip: "Outside of these hours you will be put in 'do not disturb' automatically."
midnight: "Midnight"
none: "None"
monday: "Monday"
tuesday: "Tuesday"
wednesday: "Wednesday"
thursday: "Thursday"
friday: "Friday"
saturday: "Saturday"
sunday: "Sunday"
to: "to"
activity_stream: "Activity"
preferences: "Preferences"
feature_topic_on_profile:
@ -3546,7 +3560,6 @@ en:
do_not_disturb:
title: "Do not disturb for..."
save: "Save"
label: "Do not disturb"
remaining: "%{remaining} remaining"
options:
@ -3555,6 +3568,7 @@ en:
two_hours: "2 hours"
tomorrow: "Until tomorrow"
custom: "Custom"
set_schedule: "Set a notification schedule"
# This section is exported to the javascript for i18n in the admin section
admin_js:

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateUserNotificationSchedules < ActiveRecord::Migration[6.0]
def change
create_table :user_notification_schedules do |t|
t.integer :user_id, null: false
t.boolean :enabled, null: false, default: false
t.integer :day_0_start_time, null: false
t.integer :day_0_end_time, null: false
t.integer :day_1_start_time, null: false
t.integer :day_1_end_time, null: false
t.integer :day_2_start_time, null: false
t.integer :day_2_end_time, null: false
t.integer :day_3_start_time, null: false
t.integer :day_3_end_time, null: false
t.integer :day_4_start_time, null: false
t.integer :day_4_end_time, null: false
t.integer :day_5_start_time, null: false
t.integer :day_5_end_time, null: false
t.integer :day_6_start_time, null: false
t.integer :day_6_end_time, null: false
end
add_index :user_notification_schedules, [:user_id]
add_index :user_notification_schedules, [:enabled]
add_column :do_not_disturb_timings, :scheduled, :boolean, default: false
add_index :do_not_disturb_timings, [:scheduled]
end
end

View File

@ -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

View File

@ -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) }

View File

@ -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 ?

View File

@ -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

View File

@ -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"