FEATURE: allows to set reminder

Note that reminders; as custom fields, are only editable on created event and not when creating it.
This commit is contained in:
jjaffeux 2020-08-10 16:02:12 +02:00
parent b359e9a23f
commit da91f7417d
23 changed files with 456 additions and 124 deletions

View File

@ -36,7 +36,7 @@ module DiscoursePostEvent
end
def show
event = Event.find(params[:id])
event = Event.includes(:reminders).find(params[:id])
guardian.ensure_can_see!(event.post)
serializer = EventSerializer.new(event, scope: guardian)
render_json_dump(serializer)
@ -56,6 +56,7 @@ module DiscoursePostEvent
guardian.ensure_can_edit!(event.post)
guardian.ensure_can_act_on_discourse_post_event!(event)
event.update_with_params!(event_params)
event.update_reminders!(event_reminders_params)
serializer = EventSerializer.new(event, scope: guardian)
render_json_dump(serializer)
end
@ -131,6 +132,10 @@ module DiscoursePostEvent
private
def event_reminders_params
Array(params.require(:event).permit(reminders: [:id, :value, :unit])[:reminders])
end
def event_params
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split('|')
@ -144,7 +149,7 @@ module DiscoursePostEvent
:status,
:url,
custom_fields: allowed_custom_fields,
raw_invitees: []
raw_invitees: [],
)
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module DiscoursePostEvent
class RemindersController < DiscoursePostEventController
def destroy
event = Event.find_by(id: params[:post_id])
reminder = event.reminders.find_by(id: params[:id])
guardian.ensure_can_act_on_discourse_post_event!(event)
Jobs.cancel_scheduled_job(:discourse_post_event_send_reminder, reminder_id: reminder.id)
reminder.destroy!
render json: success_json
end
end
end

View File

@ -34,34 +34,41 @@ module DiscoursePostEvent
end
end
after_commit :setup_starts_at_handler, on: [:create, :update]
def setup_starts_at_handler
if !transaction_include_any_action?([:create])
after_commit :setup_handlers, on: [:create, :update]
def setup_handlers
starts_at_changes = saved_change_to_starts_at
if starts_at_changes
new_starts_at = starts_at_changes[1]
Jobs.cancel_scheduled_job(:discourse_post_event_event_started, event_id: self.id)
Jobs.cancel_scheduled_job(:discourse_post_event_event_will_start, event_id: self.id)
if new_starts_at > Time.now
Jobs.enqueue_at(new_starts_at, :discourse_post_event_event_started, event_id: self.id)
will_start_at = new_starts_at - 1.hour
if will_start_at > Time.now
Jobs.enqueue_at(will_start_at, :discourse_post_event_event_will_start, event_id: self.id)
end
end
self.refresh_reminders!
end
if self.starts_at > Time.now
Jobs.enqueue_at(self.starts_at, :discourse_post_event_event_started, event_id: self.id)
ends_at_changes = saved_change_to_ends_at
if ends_at_changes
new_ends_at = ends_at_changes[1]
if self.starts_at - 1.hour > Time.now
Jobs.enqueue_at(self.starts_at - 1.hour, :discourse_post_event_event_will_start, event_id: self.id)
Jobs.cancel_scheduled_job(:discourse_post_event_event_ended, event_id: self.id)
if new_ends_at && new_ends_at > Time.now
Jobs.enqueue_at(new_ends_at, :discourse_post_event_event_ended, event_id: self.id)
end
end
end
after_commit :setup_ends_at_handler, on: [:create, :update]
def setup_ends_at_handler
if !transaction_include_any_action?([:create])
Jobs.cancel_scheduled_job(:discourse_post_event_event_ended, event_id: self.id)
end
if self.ends_at && self.ends_at > Time.now
Jobs.enqueue_at(self.ends_at, :discourse_post_event_event_ended, event_id: self.id)
end
end
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
has_many :reminders, foreign_key: :post_id, dependent: :delete_all
belongs_to :post, foreign_key: :id
scope :visible, -> { where(deleted_at: nil) }
@ -219,14 +226,12 @@ module DiscoursePostEvent
if events.present?
event_params = events.first
event = post.event || DiscoursePostEvent::Event.new(id: post.id)
params = {
name: event_params[:name],
starts_at: event_params[:start] || event.starts_at,
ends_at: event_params[:end],
url: event_params[:"url"],
url: event_params[:url],
status: event_params[:status].present? ? Event.statuses[event_params[:status].to_sym] : event.status,
raw_invitees: event_params[:"allowed-groups"] ? event_params[:"allowed-groups"].split(',') : nil
}
@ -254,5 +259,20 @@ module DiscoursePostEvent
self.publish_update!
end
def refresh_reminders!
self.reminders.each(&:refresh!)
end
def update_reminders!(reminders)
reminders.each do |reminder|
if reminder[:id]
model = Reminder.find(reminder[:id])
model.update!(value: reminder[:value], unit: reminder[:unit])
else
model = Reminder.create!(value: reminder[:value], unit: reminder[:unit], post_id: self.id)
end
end
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module DiscoursePostEvent
class Reminder < ActiveRecord::Base
self.table_name = 'discourse_post_event_reminders'
belongs_to :event, foreign_key: :post_id
def self.means
@means ||= Enum.new(notification: 0)
end
after_commit :refresh!, on: [:create, :update]
def refresh!
if transaction_include_any_action?([:update])
Jobs.cancel_scheduled_job(:discourse_post_event_send_reminder, reminder_id: self.id)
end
enqueue_at = self.event.starts_at - self.value.send(self.unit)
if enqueue_at > Time.now
Jobs.enqueue_at(enqueue_at, :discourse_post_event_send_reminder, reminder_id: self.id)
end
end
end
end

View File

@ -22,6 +22,7 @@ module DiscoursePostEvent
attributes :is_public
attributes :is_private
attributes :is_standalone
attributes :reminders
def can_act_on_discourse_post_event
scope.can_act_on_discourse_post_event?(object)

View File

@ -1,68 +1,6 @@
import { Result } from "discourse/adapters/rest";
import { ajax } from "discourse/lib/ajax";
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";
import { underscore } from "@ember/string";
export default DiscoursePostEventAdapter.extend({
// TODO: destroy/update/create should be improved in core to allow for nested models
destroyRecord(store, type, record) {
return ajax(
this.pathFor(store, type, {
post_id: record.post_id,
invitee_id: record.id
}),
{
type: "DELETE"
}
);
},
update(store, type, id, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;
return ajax(
this.pathFor(store, type, { invitee_id: id, post_id: attrs.post_id }),
this.getPayload("PUT", data)
).then(function(json) {
return new Result(json[typeField], json);
});
},
createRecord(store, type, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;
return ajax(
this.pathFor(store, type, attrs),
this.getPayload("POST", data)
).then(function(json) {
return new Result(json[typeField], json);
});
},
pathFor(store, type, findArgs) {
const post_id = findArgs["post_id"];
delete findArgs["post_id"];
const invitee_id = findArgs["invitee_id"];
delete findArgs["invitee_id"];
let path =
this.basePath(store, type, {}) +
"events/" +
post_id +
"/" +
underscore(store.pluralize(this.apiNameFor()));
if (invitee_id) {
path += `/${invitee_id}`;
}
return this.appendQueryParams(path, findArgs);
},
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";
export default DiscoursePostEventNestedAdapter.extend({
apiNameFor() {
return "invitee";
}

View File

@ -0,0 +1,65 @@
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";
import { underscore } from "@ember/string";
import { Result } from "discourse/adapters/rest";
import { ajax } from "discourse/lib/ajax";
export default DiscoursePostEventAdapter.extend({
// TODO: destroy/update/create should be improved in core to allow for nested models
destroyRecord(store, type, record) {
return ajax(
this.pathFor(store, type, {
post_id: record.post_id,
id: record.id
}),
{
type: "DELETE"
}
);
},
update(store, type, id, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;
return ajax(
this.pathFor(store, type, { id, post_id: attrs.post_id }),
this.getPayload("PUT", data)
).then(function(json) {
return new Result(json[typeField], json);
});
},
createRecord(store, type, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;
return ajax(
this.pathFor(store, type, attrs),
this.getPayload("POST", data)
).then(function(json) {
return new Result(json[typeField], json);
});
},
pathFor(store, type, findArgs) {
const post_id = findArgs["post_id"];
delete findArgs["post_id"];
const id = findArgs["id"];
delete findArgs["id"];
let path =
this.basePath(store, type, {}) +
"events/" +
post_id +
"/" +
underscore(store.pluralize(this.apiNameFor()));
if (id) {
path += `/${id}`;
}
return this.appendQueryParams(path, findArgs);
}
});

View File

@ -0,0 +1,7 @@
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";
export default DiscoursePostEventNestedAdapter.extend({
apiNameFor() {
return "reminder";
}
});

View File

@ -1,19 +1,24 @@
import { set } from "@ember/object";
import TextLib from "discourse/lib/text";
import Group from "discourse/models/group";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import { equal } from "@ember/object/computed";
import { set, action, computed } from "@ember/object";
import { equal, gte } from "@ember/object/computed";
import { extractError } from "discourse/lib/ajax-error";
import { Promise } from "rsvp";
import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
const DEFAULT_REMINDER = { value: 15, unit: "minutes", type: "notification" };
export default Controller.extend(ModalFunctionality, {
reminders: null,
isLoadingReminders: false,
init() {
this._super(...arguments);
this._dirtyCustomFields = false;
this.set("reminderUnits", ["minutes", "hours", "days", "weeks"]);
},
modalTitle: computed("model.eventModel.isNew", {
@ -39,9 +44,10 @@ export default Controller.extend(ModalFunctionality, {
allowsInvitees: equal("model.eventModel.status", "private"),
addReminderDisabled: gte("reminders.length", 5),
@action
onChangeCustomField(field, event) {
this._dirtyCustomFields = true;
const value = event.target.value;
set(this.model.eventModel.custom_fields, field, value);
},
@ -51,6 +57,30 @@ export default Controller.extend(ModalFunctionality, {
this.set("model.eventModel.raw_invitees", newInvitees);
},
@action
removeReminder(reminder) {
this.model.eventModel.reminders.removeObject(reminder);
if (reminder.id) {
this.set("isLoadingReminders", true);
this.store
.createRecord("discourse-post-event-reminder", {
id: reminder.id,
post_id: this.model.eventModel.id
})
.destroyRecord()
.finally(() => this.set("isLoadingReminders", false));
}
},
@action
addReminder() {
this.model.eventModel.reminders.pushObject(
Object.assign({}, DEFAULT_REMINDER)
);
},
startsAt: computed("model.eventModel.starts_at", {
get() {
return this.model.eventModel.starts_at
@ -137,17 +167,13 @@ export default Controller.extend(ModalFunctionality, {
updateEvent() {
return this.store.find("post", this.model.eventModel.id).then(post => {
const promises = [];
if (this._dirtyCustomFields) {
// custom_fields are not stored on the raw and are updated separately
const customFields = this.model.eventModel.getProperties(
"custom_fields"
);
promises.push(
this.model.eventModel
.update(customFields)
.finally(() => (this._dirtyCustomFields = false))
);
}
// custom_fields are not stored on the raw and are updated separately
const data = this.model.eventModel.getProperties(
"custom_fields",
"reminders"
);
promises.push(this.model.eventModel.update(data));
const updateRawPromise = new Promise(resolve => {
const raw = post.raw;

View File

@ -19,9 +19,7 @@ export default Controller.extend(ModalFunctionality, {
@action
removeInvitee(invitee) {
invitee
.destroyRecord({ parent: this.model })
.then(() => this._fetchInvitees());
invitee.destroyRecord().then(() => this._fetchInvitees());
},
_fetchInvitees(filter) {

View File

@ -1,4 +1,5 @@
import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
const ATTRIBUTES = {
id: {},
@ -27,6 +28,15 @@ const Event = RestModel.extend({
this.__type = "discourse-post-event-event";
},
update(data) {
return ajax(`/discourse-post-event/events/${this.id}.json`, {
type: "PUT",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({ event: data })
});
},
updateProperties() {
const attributesKeys = Object.keys(ATTRIBUTES);
return this.getProperties(attributesKeys);

View File

@ -0,0 +1,9 @@
import RestModel from "discourse/models/rest";
export default RestModel.extend({
init() {
this._super(...arguments);
this.__type = "discourse-post-event-reminder";
}
});

View File

@ -77,6 +77,48 @@
}}
{{/event-field}}
{{#if model.eventModel.reminders}}
{{#event-field class="reminders" label="discourse_post_event.builder_modal.reminders.label"}}
<div class="reminders-list">
{{#each model.eventModel.reminders as |reminder|}}
<div class="reminder-item">
{{input
class="reminder-value"
value=(readonly reminder.value)
placeholderKey="discourse_post_event.builder_modal.name.placeholder"
input=(action (mut reminder.value) value="target.value")
}}
{{combo-box
class="reminder-unit"
value=reminder.unit
nameProperty=null
valueProperty=null
content=reminderUnits
onChange=(action (mut reminder.unit))
}}
{{d-button
class="remove-reminder"
icon="times"
action=(action "removeReminder" reminder)
disabled=isLoadingReminders
}}
</div>
{{/each}}
</div>
{{d-button
class="add-reminder"
disabled=addReminderDisabled
icon="plus"
label="discourse_post_event.builder_modal.add_reminder"
action=(action "addReminder")
}}
{{/event-field}}
{{/if}}
{{#if model.eventModel.custom_fields}}
{{#if allowedCustomFields.length}}
{{#event-field label="discourse_post_event.builder_modal.custom_fields.label"}}
<p>{{i18n "discourse_post_event.builder_modal.custom_fields.description"}}</p>
@ -91,6 +133,7 @@
{{/each}}
{{/event-field}}
{{/if}}
{{/if}}
</form>
{{/conditional-loading-section}}
{{/d-modal-body}}

View File

@ -218,6 +218,16 @@ function initializeDiscoursePostEventDecorator(api) {
"calendar-day"
);
api.replaceIcon(
"notification.discourse_post_event.notifications.before_event_reminder",
"calendar-day"
);
api.replaceIcon(
"notification.discourse_post_event.notifications.after_event_reminder",
"calendar-day"
);
api.modifyClass("controller:topic", {
subscribe() {
this._super(...arguments);

View File

@ -12,6 +12,10 @@
width: 100%;
}
.name {
font-size: $font-down-1;
}
&.from {
margin-right: 2.65em;
}
@ -43,6 +47,16 @@
min-height: 200px;
.d-date-time-input-range {
.d-time-input {
.selected-name {
border: 0;
.name {
font-size: $font-down-1;
}
}
}
.to.d-date-time-input {
.pika-single.is-bound {
left: -2px !important;
@ -90,7 +104,7 @@
.custom-field-label {
font-weight: 500;
margin-bottom: 0.5em;
margin: 0.5em 0 0.25em 0;
}
.custom-field-input {
@ -132,4 +146,33 @@
}
}
}
.event-field.reminders {
display: flex;
.reminders-list {
display: flex;
flex-direction: column;
margin-bottom: 1em;
.reminder-item {
display: flex;
flex: 1 0 auto;
padding: 0.25em 0;
.reminder-value {
width: 80px;
margin-right: 0.5em;
}
.remove-reminder {
margin-left: auto;
}
}
}
.add-reminder {
align-self: flex-start;
}
}
}

View File

@ -274,6 +274,8 @@ en:
notifications:
invite_user_notification: "%{username} has invited you to %{description}"
invite_user_auto_notification: "%{username} has automatically set your attendance and invited you to %{description}"
before_event_reminder: "An event is about to start %{description}"
after_event_reminder: "An event has started %{description}"
preview:
more_than_one_event: You cant have more than one event.
edit_reason: Event updated
@ -338,6 +340,10 @@ en:
create: Create
update: Save
attach: Create event
add_reminder: Add reminder
reminders:
label: Reminders
description: A negative value will be sent after event started to `going` users who didn't visit the event since it started.
url:
label: URL
placeholder: Optional

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateRemindersTable < ActiveRecord::Migration[6.0]
def change
create_table :discourse_post_event_reminders do |t|
t.integer :post_id, null: false
t.integer :value, null: false, default: 0
t.integer :mean, null: false, default: 0
t.string :unit, null: false, default: 'minutes'
end
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Jobs
class DiscoursePostEventSendReminder < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
raise Discourse::InvalidParameters.new(:reminder_id) if args[:reminder_id].blank?
reminder = DiscoursePostEvent::Reminder.includes(event: [post: [:topic], invitees: [:user]]).find(args[:reminder_id])
event = reminder.event
invitees = event.invitees
unread_notified_user_ids = Notification.where(
read: false,
notification_type: Notification.types[:custom],
topic_id: event.post.topic_id,
post_number: 1
).pluck(:user_id)
invitees
.where(status: DiscoursePostEvent::Invitee.statuses[:going])
.where.not(user_id: unread_notified_user_ids)
.find_each do |invitee|
invitee.user.notifications.create!(
notification_type: Notification.types[:custom],
topic_id: event.post.topic_id,
post_number: event.post.post_number,
data: {
topic_title: event.post.topic.title,
display_username: invitee.user.username,
message: 'discourse_post_event.notifications.before_event_reminder'
}.to_json
)
end
end
end
end

View File

@ -83,8 +83,10 @@ after_initialize do
"../app/controllers/discourse_post_event_controller.rb",
"../app/controllers/discourse_post_event/invitees_controller.rb",
"../app/controllers/discourse_post_event/events_controller.rb",
"../app/controllers/discourse_post_event/reminders_controller.rb",
"../app/controllers/discourse_post_event/upcoming_events_controller.rb",
"../app/models/discourse_post_event/event.rb",
"../app/models/discourse_post_event/reminder.rb",
"../app/models/discourse_post_event/invitee.rb",
"../lib/discourse_post_event/event_parser.rb",
"../lib/discourse_post_event/event_validator.rb",
@ -92,6 +94,7 @@ after_initialize do
"../jobs/regular/discourse_post_event/event_will_start.rb",
"../jobs/regular/discourse_post_event/event_started.rb",
"../jobs/regular/discourse_post_event/event_ended.rb",
"../jobs/regular/discourse_post_event/send_reminder.rb",
"../lib/discourse_post_event/event_finder.rb",
"../app/serializers/discourse_post_event/invitee_serializer.rb",
"../app/serializers/discourse_post_event/event_serializer.rb"
@ -108,7 +111,7 @@ after_initialize do
get '/discourse-post-event/events/:id' => 'events#show'
delete '/discourse-post-event/events/:id' => 'events#destroy'
post '/discourse-post-event/events' => 'events#create'
put '/discourse-post-event/events/:id' => 'events#update'
put '/discourse-post-event/events/:id' => 'events#update', format: :json
post '/discourse-post-event/events/:id/csv-bulk-invite' => 'events#csv_bulk_invite'
post '/discourse-post-event/events/:id/bulk-invite' => 'events#bulk_invite', format: :json
post '/discourse-post-event/events/:id/invite' => 'events#invite'
@ -116,6 +119,7 @@ after_initialize do
post '/discourse-post-event/events/:post_id/invitees' => 'invitees#create'
get '/discourse-post-event/events/:post_id/invitees' => 'invitees#index'
delete '/discourse-post-event/events/:post_id/invitees/:id' => 'invitees#destroy'
delete '/discourse-post-event/events/:post_id/reminders/:id' => 'reminders#destroy'
get '/upcoming-events' => 'upcoming_events#index'
end

View File

@ -4,16 +4,6 @@ require 'rails_helper'
require 'securerandom'
require_relative '../fabricators/event_fabricator'
def create_post_with_event(user, extra_raw = '')
start = (Time.now - 10.seconds).utc.iso8601(3)
PostCreator.create!(
user,
title: "Sell a boat party ##{SecureRandom.alphanumeric}",
raw: "[event start=\"#{start}\" #{extra_raw}]\n[/event]",
)
end
describe Post do
Event ||= DiscoursePostEvent::Event
Invitee ||= DiscoursePostEvent::Invitee

View File

@ -13,3 +13,13 @@ Fabricator(:event, from: 'DiscoursePostEvent::Event') do
starts_at { |attrs| attrs[:starts_at] || 1.day.from_now.iso8601 }
ends_at { |attrs| attrs[:ends_at] }
end
def create_post_with_event(user, extra_raw = '')
start = (Time.now - 10.seconds).utc.iso8601(3)
PostCreator.create!(
user,
title: "Sell a boat party ##{SecureRandom.alphanumeric}",
raw: "[event start=\"#{start}\" #{extra_raw}]\n[/event]",
)
end

View File

@ -80,17 +80,17 @@ describe DiscoursePostEvent::Event do
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_ended, event_id: first_post.id)
.once
.never
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_started, event_id: first_post.id)
.once
.at_least_once
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_will_start, event_id: first_post.id)
.once
.at_least_once
Event.create!(id: first_post.id, starts_at: starts_at)
@ -131,17 +131,17 @@ describe DiscoursePostEvent::Event do
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_ended, event_id: first_post.id)
.once
.at_least_once
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_started, event_id: first_post.id)
.once
.at_least_once
Jobs
.expects(:cancel_scheduled_job)
.with(:discourse_post_event_event_will_start, event_id: first_post.id)
.once
.at_least_once
Event.create!(id: first_post.id, starts_at: Time.now - 1.day, ends_at: Time.now + 12.hours)

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
require "rails_helper"
require_relative '../fabricators/event_fabricator'
module DiscoursePostEvent
describe RemindersController do
let(:admin_1) { Fabricate(:user, admin: true) }
before do
freeze_time
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
sign_in(admin_1)
end
context 'destroying a reminder' do
let!(:post_1) { create_post_with_event(admin_1) }
before do
post_1.reload
post_1.event.reminders.create!(value: 15, unit: 'minutes')
end
context 'current user is allowed to destroy it' do
it 'detroys the reminder' do
reminders = post_1.event.reminders
reminder = reminders.first
expect(reminders.count).to eq(1)
Jobs.expects(:cancel_scheduled_job).with(:discourse_post_event_send_reminder, reminder_id: reminder.id).once
delete "/discourse-post-event/events/#{post_1.id}/reminders/#{reminder.id}.json"
expect(response.status).to eq(200)
expect(reminders.count).to eq(0)
end
end
context 'current user is not allowed to destroy it' do
let(:lurker) { Fabricate(:user) }
before do
sign_in(lurker)
end
it 'it doesnt destroy the reminder' do
reminders = post_1.event.reminders
expect(reminders.count).to eq(1)
delete "/discourse-post-event/events/#{post_1.id}/reminders/#{reminders.first.id}.json"
expect(response.status).to eq(403)
expect(reminders.count).to eq(1)
end
end
end
end
end