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:
parent
54a01701d7
commit
1a7922bea2
|
@ -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.%@"),
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
});
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
|
|
@ -15,6 +15,7 @@ export default Controller.extend({
|
|||
"like_notification_frequency",
|
||||
"allow_private_messages",
|
||||
"enable_allowed_pm_users",
|
||||
"user_notification_schedule",
|
||||
];
|
||||
|
||||
this.likeNotificationFrequencies = [
|
||||
|
|
|
@ -59,6 +59,7 @@ let userFields = [
|
|||
"watching_first_post_tags",
|
||||
"date_of_birth",
|
||||
"primary_group_id",
|
||||
"user_notification_schedule",
|
||||
];
|
||||
|
||||
export function addSaveableUserField(fieldName) {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [])
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue