diff --git a/app/controllers/discourse_calendar/invitees_controller.rb b/app/controllers/discourse_calendar/invitees_controller.rb
new file mode 100644
index 00000000..d6f1d2ba
--- /dev/null
+++ b/app/controllers/discourse_calendar/invitees_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class InviteesController < ::ApplicationController
+ before_action :ensure_logged_in
+
+ def index
+ post_event_invitees = PostEvent.find(params['post-event-id']).invitees
+
+ if params[:filter]
+ post_event_invitees = post_event_invitees.joins(:user).where("users.username LIKE '%#{params[:filter]}%'")
+ end
+
+ render json: ActiveModel::ArraySerializer.new(post_event_invitees.limit(10), each_serializer: InviteeSerializer).as_json
+ end
+
+ def update
+ invitee = Invitee.find(params[:id])
+ guardian.ensure_can_act_on_invitee!(invitee)
+ status = Invitee.statuses[invitee_params[:status].to_sym]
+ invitee.update_attendance(status: status)
+ invitee.post_event.publish_update!
+ render json: InviteeSerializer.new(invitee)
+ end
+
+ def create
+ status = Invitee.statuses[invitee_params[:status].to_sym]
+ post_event = PostEvent.find(invitee_params[:post_id])
+ guardian.ensure_can_act_on_post_event!(post_event)
+ invitee = Invitee.create!(
+ status: status,
+ post_id: invitee_params[:post_id],
+ user_id: current_user.id,
+ )
+ invitee.post_event.publish_update!
+ render json: InviteeSerializer.new(invitee)
+ end
+
+ private
+
+ def invitee_params
+ params.require(:invitee).permit(:status, :post_id)
+ end
+ end
+end
diff --git a/app/controllers/discourse_calendar/post_events_controller.rb b/app/controllers/discourse_calendar/post_events_controller.rb
new file mode 100644
index 00000000..52891cd1
--- /dev/null
+++ b/app/controllers/discourse_calendar/post_events_controller.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class PostEventsController < ::ApplicationController
+ before_action :ensure_logged_in
+
+ def index
+ post_events = PostEvent.visible.where("starts_at > ?", Time.now).limit(10)
+ render json: ActiveModel::ArraySerializer.new(
+ post_events,
+ each_serializer: PostEventSerializer,
+ scope: guardian).as_json
+ end
+
+ def show
+ post_event = DiscourseCalendar::PostEvent.find(params[:id])
+ guardian.ensure_can_see!(post_event.post)
+ serializer = PostEventSerializer.new(post_event, scope: guardian)
+ render_json_dump(serializer)
+ end
+
+ def destroy
+ post_event = DiscourseCalendar::PostEvent.find(params[:id])
+ guardian.ensure_can_act_on_post_event!(post_event)
+ post_event.publish_update!
+ post_event.destroy
+ render json: success_json
+ end
+
+ def update
+ DistributedMutex.synchronize("discourse-calendar[post-event-invitee-update]") do
+ post_event = DiscourseCalendar::PostEvent.find(params[:id])
+ guardian.ensure_can_edit!(post_event.post)
+ guardian.ensure_can_act_on_post_event!(post_event)
+ post_event.enforce_utc!(post_event_params)
+
+ case post_event_params[:status].to_i
+ when PostEvent.statuses[:private]
+ raw_invitees = Array(post_event_params[:raw_invitees])
+ post_event.update!(post_event_params.merge(raw_invitees: raw_invitees))
+ post_event.enforce_raw_invitees!
+ when PostEvent.statuses[:public]
+ post_event.update!(post_event_params.merge(raw_invitees: []))
+ when PostEvent.statuses[:standalone]
+ post_event.update!(post_event_params.merge(raw_invitees: []))
+ post_event.invitees.destroy_all
+ end
+
+ post_event.publish_update!
+ serializer = PostEventSerializer.new(post_event, scope: guardian)
+ render_json_dump(serializer)
+ end
+ end
+
+ def create
+ post_event = DiscourseCalendar::PostEvent.new(post_event_params)
+ guardian.ensure_can_edit!(post_event.post)
+ guardian.ensure_can_create_post_event!(post_event)
+ post_event.enforce_utc!(post_event_params)
+
+ case post_event_params[:status].to_i
+ when PostEvent.statuses[:private]
+ raw_invitees = Array(post_event_params[:raw_invitees])
+ post_event.update!(raw_invitees: raw_invitees)
+ post_event.fill_invitees!
+ post_event.notify_invitees!
+ when PostEvent.statuses[:public], PostEvent.statuses[:standalone]
+ post_event.update!(post_event_params.merge(raw_invitees: []))
+ end
+
+ post_event.publish_update!
+ serializer = PostEventSerializer.new(post_event, scope: guardian)
+ render_json_dump(serializer)
+ end
+
+ private
+
+ def post_event_params
+ params
+ .require(:post_event)
+ .permit(
+ :id,
+ :name,
+ :starts_at,
+ :ends_at,
+ :status,
+ :display_invitees,
+ raw_invitees: []
+ )
+ end
+ end
+end
diff --git a/app/controllers/discourse_calendar/upcoming_events_controller.rb b/app/controllers/discourse_calendar/upcoming_events_controller.rb
new file mode 100644
index 00000000..f2f15024
--- /dev/null
+++ b/app/controllers/discourse_calendar/upcoming_events_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class UpcomingEventsController < ::ApplicationController
+ before_action :ensure_logged_in
+
+ def index
+ end
+ end
+end
diff --git a/app/models/discourse_calendar/invitee.rb b/app/models/discourse_calendar/invitee.rb
new file mode 100644
index 00000000..1f321ede
--- /dev/null
+++ b/app/models/discourse_calendar/invitee.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class Invitee < ActiveRecord::Base
+ self.table_name = 'discourse_calendar_invitees'
+
+ belongs_to :post_event, foreign_key: :post_id
+ belongs_to :user
+
+ scope :with_status, ->(status) {
+ where(status: Invitee.statuses[status])
+ }
+
+ def self.statuses
+ @statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
+ end
+
+ def update_attendance(params)
+ self.update!(params)
+ end
+ end
+end
diff --git a/app/models/discourse_calendar/post_event.rb b/app/models/discourse_calendar/post_event.rb
new file mode 100644
index 00000000..98173524
--- /dev/null
+++ b/app/models/discourse_calendar/post_event.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class PostEvent < ActiveRecord::Base
+ self.table_name = 'discourse_calendar_post_events'
+
+ def self.attributes_protected_by_default
+ super - ['id']
+ end
+
+ has_many :invitees, foreign_key: :post_id, dependent: :delete_all
+ belongs_to :post, foreign_key: :id
+
+ scope :visible, -> { where(deleted_at: nil) }
+
+ validates :name,
+ length: { in: 5..30 },
+ unless: -> (post_event) { post_event.name.blank? }
+
+ validate :raw_invitees_length
+ def raw_invitees_length
+ if self.raw_invitees && self.raw_invitees.length > 10
+ errors.add(:base, I18n.t("discourse_calendar.post_event.errors.raw_invitees_length", count: 10))
+ end
+ end
+
+ validate :ends_before_start
+ def ends_before_start
+ if self.starts_at && self.ends_at && self.starts_at >= self.ends_at
+ errors.add(:base, I18n.t("discourse_calendar.post_event.errors.ends_at_before_starts_at"))
+ end
+ end
+
+ def create_invitees(attrs)
+ timestamp = Time.now
+ attrs.map! do |attr|
+ {
+ post_id: self.id,
+ created_at: timestamp,
+ updated_at: timestamp
+ }.merge(attr)
+ end
+
+ self.invitees.insert_all!(attrs)
+ end
+
+ def notify_invitees!
+ self.invitees.where(notified: false).each do |invitee|
+ invitee.user.notifications.create!(
+ notification_type: Notification.types[:custom],
+ topic_id: self.post.topic_id,
+ post_number: self.post.post_number,
+ data: {
+ topic_title: self.post.topic.title,
+ display_username: self.post.user.username,
+ message: 'discourse_calendar.invite_user_notification'
+ }.to_json
+ )
+ invitee.update!(notified: true)
+ end
+ end
+
+ def self.statuses
+ @statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
+ end
+
+ def self.display_invitees_options
+ @display_invitees_options ||= Enum.new(everyone: 0, invitees_only: 1, none: 2)
+ end
+
+ def most_likely_going(current_user, limit = SiteSetting.displayed_invitees_limit)
+ most_likely = []
+
+ if self.can_user_update_attendance(current_user)
+ most_likely << Invitee.find_or_initialize_by(
+ user_id: current_user.id,
+ post_id: self.id
+ )
+ end
+
+ most_likely << Invitee.new(
+ user_id: self.post.user_id,
+ status: Invitee.statuses[:going],
+ post_id: self.id
+ )
+
+ most_likely + self.invitees
+ .order([:status, :user_id])
+ .where.not(user_id: current_user.id)
+ .limit(limit - most_likely.count)
+ end
+
+ def publish_update!
+ self.post.publish_message!("/post-events/#{self.post.topic_id}", id: self.id)
+ end
+
+ def destroy_extraneous_invitees!
+ self.invitees.where.not(user_id: fetch_users.select(:id)).delete_all
+ end
+
+ def fill_invitees!
+ invited_users_ids = fetch_users.pluck(:id) - self.invitees.pluck(:user_id)
+ if invited_users_ids.present?
+ self.create_invitees(invited_users_ids.map { |user_id|
+ { user_id: user_id }
+ })
+ end
+ end
+
+ def fetch_users
+ @fetched_users ||= User.where(
+ id: GroupUser.where(
+ group_id: Group.where(name: self.raw_invitees).select(:id)
+ ).select(:user_id)
+ ).or(User.where(username: self.raw_invitees))
+ end
+
+ def enforce_raw_invitees!
+ self.destroy_extraneous_invitees!
+ self.fill_invitees!
+ self.notify_invitees!
+ end
+
+ def enforce_utc!(params)
+ if params['starts_at'].present?
+ params['starts_at'] = Time.parse(params['starts_at']).utc
+ end
+ if params['ends_at'].present?
+ params['ends_at'] = Time.parse(params['ends_at']).utc
+ end
+ end
+
+ def can_user_update_attendance(user)
+ self.post.user != user &&
+ self.status == PostEvent.statuses[:public] ||
+ (
+ self.status == PostEvent.statuses[:private] &&
+ self.invitees.exists?(user_id: user.id)
+ )
+ end
+ end
+end
diff --git a/app/models/guardian.rb b/app/models/guardian.rb
new file mode 100644
index 00000000..38a368c0
--- /dev/null
+++ b/app/models/guardian.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class ::Guardian
+ module CanActOnPostEvent
+ def can_act_on_post_event?(post_event)
+ @user.staff? || @user.admin? || @user.id == post_event.post.user_id
+ end
+ end
+ prepend CanActOnPostEvent
+
+ module CanActOnInvitee
+ def can_act_on_invitee?(invitee)
+ @user.staff? || @user.admin? || @user.id == invitee.user_id
+ end
+ end
+ prepend CanActOnInvitee
+
+ module CanCreatePostEvent
+ def can_create_post_event?(post_event)
+ @user.staff? || @user.admin?
+ end
+ end
+ prepend CanCreatePostEvent
+
+ module CanJoinPostEvent
+ def can_join_post_event?(post_event)
+ post_event.status === DiscourseCalendar::PostEvent.statuses[:public] || (
+ post_event.status === DiscourseCalendar::PostEvent.statuses[:private]
+ post_event.invitees.find_by(user_id: @user.id)
+ )
+ end
+ end
+ prepend CanJoinPostEvent
+end
diff --git a/app/serializers/discourse_calendar/invitee_serializer.rb b/app/serializers/discourse_calendar/invitee_serializer.rb
new file mode 100644
index 00000000..62a28faf
--- /dev/null
+++ b/app/serializers/discourse_calendar/invitee_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class InviteeSerializer < ApplicationSerializer
+ attributes :id, :status, :user
+
+ def status
+ object.status ? Invitee.statuses[object.status] : nil
+ end
+
+ def include_id?
+ object.id
+ end
+
+ def user
+ BasicUserSerializer.new(object.user, embed: :objects, root: false)
+ end
+ end
+end
diff --git a/app/serializers/discourse_calendar/post_event_serializer.rb b/app/serializers/discourse_calendar/post_event_serializer.rb
new file mode 100644
index 00000000..0beccc72
--- /dev/null
+++ b/app/serializers/discourse_calendar/post_event_serializer.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module DiscourseCalendar
+ class PostEventSerializer < ApplicationSerializer
+ attributes :id
+ attributes :creator
+ attributes :sample_invitees
+ attributes :watching_invitee
+ attributes :starts_at
+ attributes :ends_at
+ attributes :stats
+ attributes :status
+ attributes :raw_invitees
+ attributes :display_invitees
+ attributes :post
+ attributes :should_display_invitees
+ attributes :name
+ attributes :can_act_on_post_event
+ attributes :can_update_attendance
+
+ def can_act_on_post_event
+ scope.can_act_on_post_event?(object)
+ end
+
+ def status
+ PostEvent.statuses[object.status]
+ end
+
+ # lightweight post object containing
+ # only needed info for client
+ def post
+ {
+ id: object.post.id,
+ post_number: object.post.post_number,
+ url: object.post.url,
+ topic: {
+ id: object.post.topic.id,
+ title: object.post.topic.title
+ }
+ }
+ end
+
+ def should_display_invitees
+ display_invitees?
+ end
+
+ def can_update_attendance
+ object.can_user_update_attendance(scope.current_user)
+ end
+
+ def display_invitees
+ PostEvent.display_invitees_options[object.display_invitees]
+ end
+
+ def creator
+ BasicUserSerializer.new(object.post.user, embed: :objects, root: false)
+ end
+
+ def include_stats?
+ display_invitees?
+ end
+
+ def stats
+ counts = object.invitees.group(:status).count
+
+ # event creator is always going so we add one
+ going = (counts[Invitee.statuses[:going]] || 0) + 1
+ interested = counts[Invitee.statuses[:interested]] || 0
+ not_going = counts[Invitee.statuses[:not_going]] || 0
+ unanswered = counts[nil] || 0
+
+ {
+ going: going,
+ interested: interested,
+ not_going: not_going,
+ invited: going + interested + not_going + unanswered
+ }
+ end
+
+ def watching_invitee
+ if scope.current_user === object.post.user
+ watching_invitee = Invitee.new(
+ user_id: object.post.user.id,
+ status: Invitee.statuses[:going],
+ post_id: object.id
+ )
+ else
+ watching_invitee = Invitee.find_by(
+ user_id: scope.current_user.id,
+ post_id: object.id
+ )
+ end
+
+ if watching_invitee
+ InviteeSerializer.new(watching_invitee, root: false)
+ end
+ end
+
+ def include_sample_invitees?
+ display_invitees?
+ end
+
+ def sample_invitees
+ invitees = object.most_likely_going(scope.current_user)
+ ActiveModel::ArraySerializer.new(invitees, each_serializer: InviteeSerializer)
+ end
+
+ private
+
+ def display_invitees?
+ object.status != PostEvent.statuses[:standalone] &&
+ (
+ object.display_invitees == PostEvent.display_invitees_options[:everyone] ||
+ (
+ object.display_invitees == PostEvent.display_invitees_options[:invitees_only] &&
+ object.invitees.exists?(user_id: scope.current_user.id)
+ ) ||
+ (
+ object.display_invitees == PostEvent.display_invitees_options[:none] &&
+ object.post.user == scope.current_user
+ )
+ )
+ end
+ end
+end
diff --git a/app/views/upcoming-events/index.hbs b/app/views/upcoming-events/index.hbs
new file mode 100644
index 00000000..a789c9b5
--- /dev/null
+++ b/app/views/upcoming-events/index.hbs
@@ -0,0 +1 @@
+UPCOMING
diff --git a/app/views/upcoming_events/index.hbs b/app/views/upcoming_events/index.hbs
new file mode 100644
index 00000000..a789c9b5
--- /dev/null
+++ b/app/views/upcoming_events/index.hbs
@@ -0,0 +1 @@
+UPCOMING
diff --git a/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6 b/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6
new file mode 100644
index 00000000..0a631f49
--- /dev/null
+++ b/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6
@@ -0,0 +1,11 @@
+import RestAdapter from "discourse/adapters/rest";
+
+export default RestAdapter.extend({
+ basePath() {
+ return "/discourse-calendar/";
+ },
+
+ pathFor() {
+ return this._super(...arguments).replace("_", "-");
+ }
+});
diff --git a/assets/javascripts/discourse/adapters/invitee.js.es6 b/assets/javascripts/discourse/adapters/invitee.js.es6
new file mode 100644
index 00000000..5333c991
--- /dev/null
+++ b/assets/javascripts/discourse/adapters/invitee.js.es6
@@ -0,0 +1,3 @@
+import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
+
+export default DiscourseCalendarAdapter.extend();
diff --git a/assets/javascripts/discourse/adapters/post-event.js.es6 b/assets/javascripts/discourse/adapters/post-event.js.es6
new file mode 100644
index 00000000..5333c991
--- /dev/null
+++ b/assets/javascripts/discourse/adapters/post-event.js.es6
@@ -0,0 +1,3 @@
+import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
+
+export default DiscourseCalendarAdapter.extend();
diff --git a/assets/javascripts/discourse/components/event-field.js.es6 b/assets/javascripts/discourse/components/event-field.js.es6
new file mode 100644
index 00000000..8cc86e65
--- /dev/null
+++ b/assets/javascripts/discourse/components/event-field.js.es6
@@ -0,0 +1,6 @@
+import Component from "@ember/component";
+
+export default Component.extend({
+ enabled: true,
+ class: null
+});
diff --git a/assets/javascripts/discourse/controllers/event-ui-builder.js.es6 b/assets/javascripts/discourse/controllers/event-ui-builder.js.es6
new file mode 100644
index 00000000..c0295ca1
--- /dev/null
+++ b/assets/javascripts/discourse/controllers/event-ui-builder.js.es6
@@ -0,0 +1,105 @@
+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 { extractError } from "discourse/lib/ajax-error";
+
+export default Controller.extend(ModalFunctionality, {
+ modalTitle: computed("model.isNew", {
+ get() {
+ return this.model.isNew ? "create_event_title" : "update_event_title";
+ }
+ }),
+
+ allowsInvitees: equal("model.status", "private"),
+
+ @action
+ setRawInvitees(_, newInvitees) {
+ this.set("model.raw_invitees", newInvitees);
+ },
+
+ startsAt: computed("model.starts_at", {
+ get() {
+ return this.model.starts_at;
+ }
+ }),
+
+ endsAt: computed("model.ends_at", {
+ get() {
+ return this.model.ends_at;
+ }
+ }),
+
+ standaloneEvent: equal("model.status", "standalone"),
+ publicEvent: equal("model.status", "public"),
+ privateEvent: equal("model.status", "private"),
+
+ inviteesOptions: computed("model.status", function() {
+ const options = [];
+
+ if (!this.standaloneEvent) {
+ options.push({
+ label: I18n.t("event.display_invitees.everyone"),
+ value: "everyone"
+ });
+
+ if (this.privateEvent) {
+ options.push({
+ label: I18n.t("event.display_invitees.invitees_only"),
+ value: "invitees_only"
+ });
+ }
+
+ options.push({
+ label: I18n.t("event.display_invitees.none"),
+ value: "none"
+ });
+ }
+
+ return options;
+ }),
+
+ @action
+ onChangeDates(changes) {
+ this.model.setProperties({
+ starts_at: moment(changes.from)
+ .utc()
+ .toISOString(),
+ ends_at: changes.to
+ ? moment(changes.to)
+ .utc()
+ .toISOString()
+ : null
+ });
+ },
+
+ @action
+ destroyPostEvent() {
+ bootbox.confirm(
+ I18n.t("event.ui_builder.confirm_delete"),
+ I18n.t("no_value"),
+ I18n.t("yes_value"),
+ confirmed => {
+ if (confirmed) {
+ this.model.destroyRecord().then(() => this.send("closeModal"));
+ }
+ }
+ );
+ },
+
+ @action
+ createEvent() {
+ this.model
+ .save()
+ .then(() => this.send("closeModal"))
+ .catch(e => this.flash(extractError(e), "error"));
+ },
+
+ @action
+ updateEvent() {
+ this.model
+ .save()
+ .then(() => this.send("closeModal"))
+ .catch(e => this.flash(extractError(e), "error"));
+ }
+});
diff --git a/assets/javascripts/discourse/controllers/post-event-invitees.js.es6 b/assets/javascripts/discourse/controllers/post-event-invitees.js.es6
new file mode 100644
index 00000000..7ee57aad
--- /dev/null
+++ b/assets/javascripts/discourse/controllers/post-event-invitees.js.es6
@@ -0,0 +1,28 @@
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+import Controller from "@ember/controller";
+import { debounce } from "@ember/runloop";
+import { action } from "@ember/object";
+
+export default Controller.extend(ModalFunctionality, {
+ invitees: null,
+ filter: null,
+ isLoading: false,
+
+ onShow() {
+ this._fetchInvitees();
+ },
+
+ @action
+ onFilterChanged(filter) {
+ debounce(this, this._fetchInvitees, filter, 250);
+ },
+
+ _fetchInvitees(filter) {
+ this.set("isLoading", true);
+
+ this.store
+ .findAll("invitee", { "post-event-id": this.model.id, filter })
+ .then(invitees => this.set("invitees", invitees))
+ .finally(() => this.set("isLoading", false));
+ }
+});
diff --git a/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6 b/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6
new file mode 100644
index 00000000..c93fcb89
--- /dev/null
+++ b/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6
@@ -0,0 +1,9 @@
+import Controller from "@ember/controller";
+
+export default Controller.extend({
+ loadPostEvents(params) {
+ this.store.findAll("post-event", params).then(postEvents => {
+ this.set("postEvents", postEvents);
+ });
+ }
+});
diff --git a/assets/javascripts/discourse/lib/google-calendar.js.es6 b/assets/javascripts/discourse/lib/google-calendar.js.es6
new file mode 100644
index 00000000..8ffad550
--- /dev/null
+++ b/assets/javascripts/discourse/lib/google-calendar.js.es6
@@ -0,0 +1,26 @@
+import EmberObject from "@ember/object";
+
+export default EmberObject.extend({
+ init(params = {}) {
+ this.title = params.title;
+ this.startsAt = moment(params.startsAt);
+ this.endsAt = params.endsAt ? moment(params.endsAt) : null;
+ },
+
+ generateLink() {
+ const title = encodeURIComponent(this.title);
+ let dates = [this._formatDate(this.startsAt)];
+ if (this.endsAt) {
+ dates.push(this._formatDate(this.endsAt));
+ dates = `dates=${dates.join("/")}`;
+ } else {
+ dates = `date=${dates.join("")}`;
+ }
+
+ return `https://www.google.com/calendar/event?action=TEMPLATE&text=${title}&${dates}`;
+ },
+
+ _formatDate(date) {
+ return date.toISOString().replace(/-|:|\.\d\d\d/g, "");
+ }
+});
diff --git a/assets/javascripts/discourse/models/invitee.js.es6 b/assets/javascripts/discourse/models/invitee.js.es6
new file mode 100644
index 00000000..d087ac72
--- /dev/null
+++ b/assets/javascripts/discourse/models/invitee.js.es6
@@ -0,0 +1,9 @@
+import RestModel from "discourse/models/rest";
+
+export default RestModel.extend({
+ init() {
+ this._super(...arguments);
+
+ this.__type = "invitee";
+ }
+});
diff --git a/assets/javascripts/discourse/models/post-event.js.es6 b/assets/javascripts/discourse/models/post-event.js.es6
new file mode 100644
index 00000000..9e04f1df
--- /dev/null
+++ b/assets/javascripts/discourse/models/post-event.js.es6
@@ -0,0 +1,72 @@
+import RestModel from "discourse/models/rest";
+// import { ajax } from "discourse/lib/ajax";
+
+// const BASE_URL = "/discourse-calendar/post-events";
+
+const ATTRIBUTES = {
+ id: {},
+ name: {},
+ starts_at: {},
+ ends_at: {},
+ raw_invitees: {},
+ display_invitees: {
+ transform(value) {
+ return DISPLAY_INVITEES_OPTIONS[value];
+ }
+ },
+ status: {
+ transform(value) {
+ return STATUSES[value];
+ }
+ }
+};
+
+const DISPLAY_INVITEES_OPTIONS = {
+ everyone: 0,
+ invitees_only: 1,
+ none: 2
+};
+
+const STATUSES = {
+ standalone: 0,
+ public: 1,
+ private: 2
+};
+
+const PostEvent = RestModel.extend({
+ init() {
+ this._super(...arguments);
+
+ this.__type = "post-event";
+ },
+
+ updateProperties() {
+ const attributesKeys = Object.keys(ATTRIBUTES);
+ return this.getProperties(attributesKeys);
+ },
+
+ createProperties() {
+ const attributesKeys = Object.keys(ATTRIBUTES);
+ return this.getProperties(attributesKeys);
+ },
+
+ _transformProps(props) {
+ const attributesKeys = Object.keys(ATTRIBUTES);
+ attributesKeys.forEach(key => {
+ const attribute = ATTRIBUTES[key];
+ if (attribute.transform) {
+ props[key] = attribute.transform(props[key]);
+ }
+ });
+ },
+
+ beforeUpdate(props) {
+ this._transformProps(props);
+ },
+
+ beforeCreate(props) {
+ this._transformProps(props);
+ }
+});
+
+export default PostEvent;
diff --git a/assets/javascripts/discourse/routes/upcoming-events-index.js.es6 b/assets/javascripts/discourse/routes/upcoming-events-index.js.es6
new file mode 100644
index 00000000..60b04e9f
--- /dev/null
+++ b/assets/javascripts/discourse/routes/upcoming-events-index.js.es6
@@ -0,0 +1,15 @@
+import Route from "@ember/routing/route";
+
+export default Route.extend({
+ queryParams: {
+ invited: { refreshModel: true, replace: true }
+ },
+
+ model(params) {
+ return params;
+ },
+
+ setupController(controller, params) {
+ controller.loadPostEvents(params);
+ }
+});
diff --git a/assets/javascripts/discourse/routes/upcoming-events.js.es6 b/assets/javascripts/discourse/routes/upcoming-events.js.es6
new file mode 100644
index 00000000..f7f5da26
--- /dev/null
+++ b/assets/javascripts/discourse/routes/upcoming-events.js.es6
@@ -0,0 +1,3 @@
+import Route from "@ember/routing/route";
+
+export default Route.extend({});
diff --git a/assets/javascripts/discourse/templates/components/event-field.hbs b/assets/javascripts/discourse/templates/components/event-field.hbs
new file mode 100644
index 00000000..7be8e11a
--- /dev/null
+++ b/assets/javascripts/discourse/templates/components/event-field.hbs
@@ -0,0 +1,10 @@
+{{#if enabled}}
+
+
+ {{i18n label}}
+
+
+ {{yield}}
+
+
+{{/if}}
diff --git a/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs b/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs
new file mode 100644
index 00000000..b39a1681
--- /dev/null
+++ b/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs
@@ -0,0 +1,122 @@
+{{#d-modal-body
+ title=(concat "event.ui_builder." modalTitle)
+ class="event-ui-builder"
+}}
+ {{#conditional-loading-section isLoading=model.isSaving}}
+
+ {{/conditional-loading-section}}
+{{/d-modal-body}}
+
+
+
diff --git a/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs b/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs
new file mode 100644
index 00000000..c303ee69
--- /dev/null
+++ b/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs
@@ -0,0 +1,28 @@
+{{#d-modal-body
+ title="event.post-event-invitees-modal.title"
+}}
+ {{input
+ value=(readonly filter)
+ input=(action "onFilterChanged" value="target.value")
+ class="filter"
+ placeholderKey="event.post-event-invitees-modal.filter_placeholder"
+ }}
+
+ {{#conditional-loading-spinner condition=isLoading}}
+
+ {{#each invitees as |invitee|}}
+ -
+
+ {{avatar invitee.user imageSize="medium"}}
+ {{format-username invitee.user.username}}
+
+ {{#if invitee.status}}
+
+ {{i18n (concat "event.invitee_status." invitee.status)}}
+
+ {{/if}}
+
+ {{/each}}
+
+ {{/conditional-loading-spinner}}
+{{/d-modal-body}}
diff --git a/assets/javascripts/discourse/templates/upcoming-events-index.hbs b/assets/javascripts/discourse/templates/upcoming-events-index.hbs
new file mode 100644
index 00000000..a535a029
--- /dev/null
+++ b/assets/javascripts/discourse/templates/upcoming-events-index.hbs
@@ -0,0 +1,31 @@
+
+
+
+ | id |
+ creator |
+ status |
+ starts at |
+
+
+
+ {{#each postEvents as |postEvent|}}
+
+ |
+
+ {{format-post-event-name postEvent}}
+
+ |
+
+ {{avatar postEvent.creator imageSize="tiny"}}
+ {{format-username postEvent.creator.username}}
+ |
+
+ {{postEvent.status}}
+ |
+
+ {{format-future-date postEvent.starts_at}}
+ |
+
+ {{/each}}
+
+
diff --git a/assets/javascripts/discourse/templates/upcoming-events.hbs b/assets/javascripts/discourse/templates/upcoming-events.hbs
new file mode 100644
index 00000000..c24cd689
--- /dev/null
+++ b/assets/javascripts/discourse/templates/upcoming-events.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/assets/javascripts/discourse/upcoming-events-route-map.js.es6 b/assets/javascripts/discourse/upcoming-events-route-map.js.es6
new file mode 100644
index 00000000..4c4cd652
--- /dev/null
+++ b/assets/javascripts/discourse/upcoming-events-route-map.js.es6
@@ -0,0 +1,5 @@
+export default function() {
+ this.route("upcoming-events", { path: "/upcoming-events" }, function() {
+ this.route("index", { path: "/" });
+ });
+}
diff --git a/assets/javascripts/widgets/discourse-group-timezone-new-day.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezone-new-day.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezone-new-day.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezone-new-day.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezone.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezone.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezone.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezone.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-filter.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-filter.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-filter.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-filter.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-header.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-header.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-header.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-header.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-member.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-member.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-member.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-member.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-reset.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-reset.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-reset.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-reset.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-slider.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-slider.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-slider.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-slider.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones-time-traveler.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-time-traveler.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones-time-traveler.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones-time-traveler.js.es6
diff --git a/assets/javascripts/widgets/discourse-group-timezones.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones.js.es6
similarity index 100%
rename from assets/javascripts/widgets/discourse-group-timezones.js.es6
rename to assets/javascripts/discourse/widgets/discourse-group-timezones.js.es6
diff --git a/assets/javascripts/discourse/widgets/post-event-dates.js.es6 b/assets/javascripts/discourse/widgets/post-event-dates.js.es6
new file mode 100644
index 00000000..b741d293
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event-dates.js.es6
@@ -0,0 +1,11 @@
+import hbs from "discourse/widgets/hbs-compiler";
+import { createWidget } from "discourse/widgets/widget";
+
+export default createWidget("post-event-dates", {
+ tagName: "section.post-event-dates",
+
+ template: hbs`
+ {{d-icon "clock"}}
+ {{{attrs.localDates}}}
+ `
+});
diff --git a/assets/javascripts/discourse/widgets/post-event-host.js.es6 b/assets/javascripts/discourse/widgets/post-event-host.js.es6
new file mode 100644
index 00000000..05cb3d30
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event-host.js.es6
@@ -0,0 +1,33 @@
+import { h } from "virtual-dom";
+import { avatarImg } from "discourse/widgets/post";
+import { createWidget } from "discourse/widgets/widget";
+import { formatUsername } from "discourse/lib/utilities";
+
+export default createWidget("post-event-creator", {
+ tagName: "span.post-event-creator",
+
+ html(attrs) {
+ const { name, username, avatar_template } = attrs.user;
+
+ return h(
+ "a",
+ {
+ attributes: {
+ class: "topic-invitee-avatar",
+ "data-user-card": username
+ }
+ },
+ [
+ avatarImg("tiny", {
+ template: avatar_template,
+ username: name || formatUsername(username)
+ }),
+ h(
+ "span",
+ { attributes: { class: "username" } },
+ name || formatUsername(username)
+ )
+ ]
+ );
+ }
+});
diff --git a/assets/javascripts/discourse/widgets/post-event-invitee.js.es6 b/assets/javascripts/discourse/widgets/post-event-invitee.js.es6
new file mode 100644
index 00000000..c224afd3
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event-invitee.js.es6
@@ -0,0 +1,59 @@
+import { h } from "virtual-dom";
+import { avatarImg } from "discourse/widgets/post";
+import { createWidget } from "discourse/widgets/widget";
+import { formatUsername } from "discourse/lib/utilities";
+
+export default createWidget("post-event-invitee", {
+ tagName: "li.post-event-invitee",
+
+ buildClasses(attrs) {
+ return [
+ Ember.isPresent(attrs.invitee.status)
+ ? `status-${attrs.invitee.status}`
+ : `unanswered`
+ ];
+ },
+
+ html(attrs) {
+ const { name, username, avatar_template } = attrs.invitee.user;
+
+ let statusIcon;
+ switch (attrs.invitee.status) {
+ case "going":
+ statusIcon = "fa-check";
+ break;
+ case "interested":
+ statusIcon = "fa-question";
+ break;
+ case "not_going":
+ statusIcon = "fa-times";
+ break;
+ }
+
+ const avatarContent = [
+ avatarImg("large", {
+ template: avatar_template,
+ username: name || formatUsername(username)
+ })
+ ];
+
+ if (statusIcon) {
+ avatarContent.push(
+ this.attach("avatar-flair", {
+ primary_group_name: `status-${attrs.invitee.status}`,
+ primary_group_flair_url: statusIcon
+ })
+ );
+ }
+ return h(
+ "a",
+ {
+ attributes: {
+ class: "topic-invitee-avatar",
+ "data-user-card": username
+ }
+ },
+ avatarContent
+ );
+ }
+});
diff --git a/assets/javascripts/discourse/widgets/post-event-invitees.js.es6 b/assets/javascripts/discourse/widgets/post-event-invitees.js.es6
new file mode 100644
index 00000000..96f6b162
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event-invitees.js.es6
@@ -0,0 +1,43 @@
+import hbs from "discourse/widgets/hbs-compiler";
+import { createWidget } from "discourse/widgets/widget";
+
+export default createWidget("post-event-invitees", {
+ tagName: "section.post-event-invitees",
+
+ transform(attrs) {
+ return {
+ showAll: attrs.postEvent.stats && attrs.postEvent.stats.invited > 10
+ };
+ },
+
+ template: hbs`
+
+
+ {{#each attrs.postEvent.sample_invitees as |invitee|}}
+ {{attach
+ widget="post-event-invitee"
+ attrs=(hash invitee=invitee)
+ }}
+ {{/each}}
+
+ `
+});
diff --git a/assets/javascripts/discourse/widgets/post-event-status.js.es6 b/assets/javascripts/discourse/widgets/post-event-status.js.es6
new file mode 100644
index 00000000..8a1dfc37
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event-status.js.es6
@@ -0,0 +1,39 @@
+import { h } from "virtual-dom";
+import { createWidget } from "discourse/widgets/widget";
+
+export default createWidget("post-event-status", {
+ tagName: "select.post-event-status",
+
+ change(event) {
+ this.sendWidgetAction("changeWatchingInviteeStatus", event.target.value);
+ },
+
+ buildClasses(attrs) {
+ if (attrs.watchingInvitee) {
+ return `status-${attrs.watchingInvitee.status}`;
+ }
+ },
+
+ html(attrs) {
+ const statuses = [
+ { value: null, name: I18n.t("event.invitee_status.unknown") },
+ { value: "going", name: I18n.t("event.invitee_status.going") },
+ { value: "interested", name: I18n.t("event.invitee_status.interested") },
+ { value: "not_going", name: I18n.t("event.invitee_status.not_going") }
+ ];
+
+ const value = attrs.watchingInvitee ? attrs.watchingInvitee.status : null;
+
+ return statuses.map(status =>
+ h(
+ "option",
+ {
+ value: status.value,
+ class: `status-${status.value}`,
+ selected: status.value === value
+ },
+ status.name
+ )
+ );
+ }
+});
diff --git a/assets/javascripts/discourse/widgets/post-event.js.es6 b/assets/javascripts/discourse/widgets/post-event.js.es6
new file mode 100644
index 00000000..26811752
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/post-event.js.es6
@@ -0,0 +1,184 @@
+import EmberObject from "@ember/object";
+import showModal from "discourse/lib/show-modal";
+import hbs from "discourse/widgets/hbs-compiler";
+import { createWidget } from "discourse/widgets/widget";
+import GoogleCalendar from "discourse/plugins/discourse-calendar/discourse/lib/google-calendar";
+import { routeAction } from "discourse/helpers/route-action";
+import { iconNode } from "discourse-common/lib/icon-library";
+
+export default createWidget("post-event", {
+ tagName: "div.post-event",
+
+ buildKey: attrs => `post-event-${attrs.id}`,
+
+ buildAttributes(attrs) {
+ return { style: `height:${attrs.widgetHeight}px` };
+ },
+
+ buildClasses() {
+ if (this.state.postEvent) {
+ return ["has-post-event"];
+ }
+ },
+
+ showAllInvitees(postId) {
+ this.store.find("post-event", postId).then(postEvent => {
+ showModal("post-event-invitees", {
+ model: postEvent
+ });
+ });
+ },
+
+ editPostEvent(postId) {
+ this.store.find("post-event", postId).then(postEvent => {
+ showModal("event-ui-builder", {
+ model: postEvent,
+ modalClass: "event-ui-builder-modal"
+ });
+ });
+ },
+
+ changeWatchingInviteeStatus(status) {
+ if (this.state.postEvent.watching_invitee) {
+ this.store.update("invitee", this.state.postEvent.watching_invitee.id, {
+ status
+ });
+ } else {
+ this.store
+ .createRecord("invitee")
+ .save({ post_id: this.state.postEvent.id, status });
+ }
+ },
+
+ defaultState(attrs) {
+ return {
+ postEvent: attrs.postEvent
+ };
+ },
+
+ sendPMToCreator() {
+ const router = this.register.lookup("service:router")._router;
+ routeAction(
+ "composePrivateMessage",
+ router,
+ EmberObject.create(this.state.postEvent.creator),
+ EmberObject.create(this.state.postEvent.post)
+ ).call();
+ },
+
+ addToGoogleCalendar() {
+ const link = GoogleCalendar.create({
+ title: this.state.postEvent.name || this.state.postEvent.post.topic.title,
+ startsAt: this.state.postEvent.starts_at,
+ endsAt: this.state.postEvent.ends_at
+ }).generateLink();
+
+ window.open(link, "_blank");
+ },
+
+ transform() {
+ const postEvent = this.state.postEvent;
+
+ let statusIcon = "times";
+ if (postEvent.status === "private") {
+ statusIcon = "lock";
+ }
+ if (postEvent.status === "public") {
+ statusIcon = "unlock";
+ }
+
+ return {
+ postEventStatusLabel: I18n.t(
+ `event.post_event_status.${postEvent.status}.title`
+ ),
+ postEventStatusDescription: I18n.t(
+ `event.post_event_status.${postEvent.status}.description`
+ ),
+ startsAtMonth: moment(postEvent.starts_at).format("MMM"),
+ startsAtDay: moment(postEvent.starts_at).format("D"),
+ postEventName: postEvent.name || postEvent.post.topic.title,
+ statusClass: `status ${postEvent.status}`,
+ statusIcon: iconNode(statusIcon)
+ };
+ },
+
+ template: hbs`
+ {{#if state.postEvent}}
+
+
+ {{#if state.postEvent.can_update_attendance}}
+
+ {{attach
+ widget="post-event-status"
+ attrs=(hash
+ watchingInvitee=this.state.postEvent.watching_invitee
+ )
+ }}
+
+ {{/if}}
+
+
+
+ {{attach widget="post-event-dates" attrs=(hash localDates=attrs.localDates postEvent=state.postEvent)}}
+
+ {{#if state.postEvent.should_display_invitees}}
+
+ {{attach widget="post-event-invitees" attrs=(hash postEvent=state.postEvent)}}
+ {{/if}}
+
+
+ {{/if}}
+ `
+});
diff --git a/assets/javascripts/helpers/format-future-date.js.es6 b/assets/javascripts/helpers/format-future-date.js.es6
new file mode 100644
index 00000000..ca73642f
--- /dev/null
+++ b/assets/javascripts/helpers/format-future-date.js.es6
@@ -0,0 +1,5 @@
+import { htmlHelper } from "discourse-common/lib/helpers";
+
+export default htmlHelper(postEvent => {
+ return moment(postEvent.starts_at).format("LLL");
+});
diff --git a/assets/javascripts/helpers/format-post-event-name.js.es6 b/assets/javascripts/helpers/format-post-event-name.js.es6
new file mode 100644
index 00000000..332a4d5f
--- /dev/null
+++ b/assets/javascripts/helpers/format-post-event-name.js.es6
@@ -0,0 +1,5 @@
+import { htmlHelper } from "discourse-common/lib/helpers";
+
+export default htmlHelper(postEvent => {
+ return postEvent.name || postEvent.post.topic.title;
+});
diff --git a/assets/javascripts/initializers/add-event-ui-builder.js.es6 b/assets/javascripts/initializers/add-event-ui-builder.js.es6
new file mode 100644
index 00000000..c6f785a8
--- /dev/null
+++ b/assets/javascripts/initializers/add-event-ui-builder.js.es6
@@ -0,0 +1,56 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+import showModal from "discourse/lib/show-modal";
+import { Promise } from "rsvp";
+
+function initializeEventUIBuilder(api) {
+ api.decorateWidget("hamburger-menu:generalLinks", () => {
+ return {
+ icon: "calendar-day",
+ route: "upcoming-events",
+ label: "upcoming_events.title"
+ };
+ });
+
+ api.attachWidgetAction("post", "showEventUIBuilder", function(postId) {
+ return new Promise(resolve => {
+ if (postId) {
+ this.store
+ .find("post-event", postId)
+ .then(resolve)
+ .catch(() => {
+ const postEvent = this.store.createRecord("post-event");
+ postEvent.setProperties({
+ id: postId,
+ status: "public",
+ display_invitees: "everyone"
+ });
+ resolve(postEvent);
+ });
+ } else if (this.model) {
+ resolve(this.model);
+ }
+ }).then(model => {
+ showModal("event-ui-builder", {
+ model,
+ modalClass: "event-ui-builder-modal"
+ });
+ });
+ });
+
+ api.decorateWidget("post-admin-menu:after", dec => {
+ return dec.attach("post-admin-menu-button", {
+ icon: "calendar-day",
+ label: "event.ui_builder.attach",
+ action: "showEventUIBuilder",
+ actionParam: dec.attrs.id
+ });
+ });
+}
+
+export default {
+ name: "add-event-ui-builder",
+
+ initialize() {
+ withPluginApi("0.8.7", initializeEventUIBuilder);
+ }
+};
diff --git a/assets/javascripts/initializers/post-event-decorator.js.es6 b/assets/javascripts/initializers/post-event-decorator.js.es6
new file mode 100644
index 00000000..c92590e9
--- /dev/null
+++ b/assets/javascripts/initializers/post-event-decorator.js.es6
@@ -0,0 +1,139 @@
+import { cookAsync } from "discourse/lib/text";
+import WidgetGlue from "discourse/widgets/glue";
+import { getRegister } from "discourse-common/lib/get-owner";
+import { withPluginApi } from "discourse/lib/plugin-api";
+import { schedule } from "@ember/runloop";
+
+function _decoratePostEvent(api, cooked, post) {
+ _attachWidget(api, cooked, post);
+}
+
+let _glued = [];
+
+function cleanUp() {
+ _glued.forEach(g => g.cleanUp());
+ _glued = [];
+}
+
+function _attachWidget(api, cooked, postEvent) {
+ const existing = cooked.querySelector(".post-event");
+
+ if (postEvent) {
+ let widgetHeight = 170;
+ if (postEvent.should_display_invitees) {
+ widgetHeight += 125;
+ }
+
+ if (postEvent.can_update_attendance) {
+ widgetHeight += 65;
+ }
+
+ const postEventContainer = existing || document.createElement("div");
+ postEventContainer.classList.add("post-event");
+ postEventContainer.classList.add("is-loading");
+ postEventContainer.style.height = `${widgetHeight}px`;
+ postEventContainer.innerHTML = '';
+ cooked.prepend(postEventContainer);
+
+ const dates = [];
+ let format;
+
+ const startsAt = moment(postEvent.starts_at);
+ if (
+ startsAt.hours() > 0 ||
+ startsAt.minutes() > 0 ||
+ (postEvent.ends_at &&
+ (moment(postEvent.ends_at).hours() > 0 ||
+ moment(postEvent.ends_at).minutes() > 0))
+ ) {
+ format = "LLL";
+ } else {
+ format = "LL";
+ }
+
+ dates.push(
+ `[date=${moment
+ .utc(postEvent.starts_at)
+ .format("YYYY-MM-DD")} time=${moment
+ .utc(postEvent.starts_at)
+ .format("HH:mm")} format=${format}]`
+ );
+
+ if (postEvent.ends_at) {
+ const endsAt = moment.utc(postEvent.ends_at);
+ dates.push(
+ `[date=${endsAt.format("YYYY-MM-DD")} time=${endsAt.format(
+ "HH:mm"
+ )} format=${format}]`
+ );
+ }
+
+ cookAsync(dates.join(" → ")).then(result => {
+ const glue = new WidgetGlue("post-event", getRegister(api), {
+ postEvent,
+ widgetHeight,
+ localDates: $(result.string).html()
+ });
+
+ glue.appendTo(postEventContainer);
+ _glued.push(glue);
+
+ schedule("afterRender", () => {
+ $(
+ ".discourse-local-date",
+ $(`[data-post-id="${postEvent.id}"]`)
+ ).applyLocalDates();
+ });
+ });
+ } else {
+ existing && existing.remove();
+ }
+}
+
+function initializePostEventDecorator(api) {
+ api.cleanupStream(cleanUp);
+
+ api.decorateCooked(($cooked, helper) => {
+ if (helper) {
+ const post = helper.getModel();
+ if (post.post_event) {
+ _decoratePostEvent(api, $cooked[0], post.post_event);
+ }
+ }
+ });
+
+ api.replaceIcon(
+ "notification.discourse_calendar.invite_user_notification",
+ "calendar-day"
+ );
+
+ api.modifyClass("controller:topic", {
+ subscribe() {
+ this._super(...arguments);
+ this.messageBus.subscribe("/post-events/" + this.get("model.id"), msg => {
+ const postNode = document.querySelector(
+ `.onscreen-post[data-post-id="${msg.id}"] .cooked`
+ );
+
+ if (postNode) {
+ this.store
+ .find("post-event", msg.id)
+ .then(postEvent => _decoratePostEvent(api, postNode, postEvent))
+ .catch(() => _decoratePostEvent(api, postNode));
+ }
+ });
+ },
+ unsubscribe() {
+ this.messageBus.unsubscribe("/post-events/*");
+ this._super(...arguments);
+ }
+ });
+}
+
+export default {
+ name: "post-event-decorator",
+
+ initialize() {
+ withPluginApi("0.8.7", initializePostEventDecorator);
+ }
+};
diff --git a/assets/stylesheets/common/post-event.scss b/assets/stylesheets/common/post-event.scss
new file mode 100644
index 00000000..989a7e1e
--- /dev/null
+++ b/assets/stylesheets/common/post-event.scss
@@ -0,0 +1,354 @@
+.event-ui-builder-modal {
+ .modal-inner-container {
+ min-width: 50vw;
+ }
+
+ .modal-body {
+ min-height: 200px;
+
+ .d-date-time-input-range {
+ width: auto;
+ padding: 0;
+ border: 0;
+ }
+ }
+
+ .modal-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .event-field {
+ display: flex;
+ margin: 1em 0;
+ flex-direction: column;
+
+ &.name {
+ input {
+ width: 100%;
+ }
+ }
+
+ .event-field-label {
+ display: flex;
+ min-height: 1px;
+ padding-top: 0;
+ top: 0;
+ vertical-align: middle;
+ align-items: center;
+
+ .label {
+ font-weight: 700;
+ margin-bottom: 0.5em;
+ }
+ }
+
+ .event-field-control {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+
+ .radio-label {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1em;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ input[type="radio"] {
+ width: auto;
+ margin-right: 0.5em;
+ }
+
+ .message {
+ margin: 0 0 0 1em;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+
+ .description {
+ color: $primary-medium;
+ }
+ }
+ }
+
+ .ac-wrap {
+ max-width: 450px;
+ }
+
+ input {
+ margin: 0;
+ }
+ }
+ }
+}
+
+.post-event {
+ border: 1px solid $primary-low;
+ display: flex;
+
+ &.is-loading {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &.has-post-event {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .post-event-footer {
+ padding: 0.5em;
+ background: $primary-very-low;
+ margin-top: auto;
+ }
+
+ .post-event-header {
+ display: flex;
+ align-items: center;
+ padding: 1em;
+
+ .actions {
+ margin-bottom: auto;
+ margin-left: auto;
+ }
+ }
+
+ .post-event-date {
+ display: flex;
+ flex-direction: column;
+ width: auto;
+ margin-right: 1em;
+
+ .month {
+ text-align: center;
+ color: red;
+ font-size: $font-down-1;
+ text-transform: uppercase;
+ }
+
+ .day {
+ text-align: center;
+ font-weight: 500;
+ font-size: $font-up-2;
+ }
+ }
+
+ .post-event-info {
+ display: flex;
+ flex-direction: column;
+
+ .status-and-name {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 0.25em;
+
+ .name {
+ font-weight: 700;
+ margin-left: 0.25em;
+ @include ellipsis;
+ max-width: 45vw;
+ }
+
+ .status {
+ text-transform: lowercase;
+ padding: 0.25em 0.5em;
+ font-size: $font-down-1;
+ border-radius: 3px;
+ background-color: $primary-very-low;
+ color: $primary-medium;
+ flex-wrap: no-wrap;
+ display: flex;
+ align-items: center;
+
+ .d-icon {
+ margin-right: 0.5em;
+ }
+ }
+ }
+
+ .creators {
+ color: $primary-medium;
+ font-size: $font-down-1;
+ }
+ }
+
+ .post-event-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1em;
+
+ .post-event-status {
+ &.status-going {
+ color: $success;
+ }
+
+ &.status-not_going {
+ color: $danger;
+ }
+ }
+ }
+
+ .post-event-creator {
+ .username {
+ margin-left: 0.25em;
+ }
+ }
+
+ .post-event-invitees {
+ padding: 1em;
+ overflow-y: auto;
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1em;
+
+ .show-all {
+ margin-left: 0.5em;
+ text-transform: lowercase;
+ }
+
+ .post-event-invitees-status {
+ font-weight: 700;
+
+ .invited {
+ font-weight: 500;
+ color: $primary-medium;
+ }
+ }
+ }
+
+ .post-event-invitees-avatars {
+ padding: 0;
+ margin: 0;
+ display: inline-flex;
+ flex-wrap: wrap;
+
+ .post-event-invitee {
+ list-style: none;
+ margin-right: 0.5em;
+ margin-bottom: 0.5em;
+
+ &.unanswered {
+ opacity: 0.25;
+ }
+ }
+
+ .topic-invitee-avatar {
+ position: relative;
+ display: flex;
+
+ .avatar-flair {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ background: $secondary;
+ border-radius: 50%;
+ height: 16px;
+ width: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $primary-medium;
+ border: 1px solid $primary-low;
+
+ &.avatar-flair-status-going {
+ color: $success;
+ }
+
+ &.avatar-flair-status-not_going {
+ color: $danger;
+ }
+
+ .d-icon {
+ font-size: $font-down-3;
+ }
+ }
+ }
+ }
+ }
+
+ hr {
+ margin: 0;
+ }
+
+ .post-event-dates {
+ display: flex;
+ align-items: center;
+ padding: 1em;
+
+ .d-icon {
+ color: $primary-medium;
+ }
+
+ .date {
+ color: $primary-high;
+ margin-left: 1em;
+
+ .discourse-local-date {
+ .d-icon {
+ display: none;
+ }
+ }
+ }
+
+ .separator {
+ color: $primary-high;
+ margin: 0 0.5em;
+ text-align: center;
+ }
+ }
+}
+
+.post-event-invitees-modal {
+ .filter {
+ width: 100%;
+ }
+ .invitees {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ flex-direction: column;
+ .invitee {
+ list-style: none;
+ display: flex;
+ flex: 1;
+ padding: 0.5em;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid $primary-low;
+
+ &:last-child {
+ border: none;
+ }
+
+ .status {
+ margin-left: 1em;
+
+ &.going {
+ color: $success;
+ }
+
+ &.not_going {
+ color: $danger;
+ }
+ }
+ }
+ }
+}
+
+.upcoming-events-table {
+ width: 100%;
+
+ tbody {
+ tr td {
+ padding: 0.5em;
+ }
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index ea8a3079..e6267848 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1,6 +1,7 @@
en:
js:
discourse_calendar:
+ invite_user_notification: "%{username} invited you to: %{description}"
on_holiday: "On Holiday"
holiday: "Holiday"
add_to_calendar: "Add to calendar"
@@ -8,6 +9,51 @@ en:
title: "Timezone"
instructions: "Your current timezone is %{timezone}."
none: "Select a timezone..."
+ event:
+ display_invitees:
+ everyone: "To everyone"
+ invitees_only: "To invited users only"
+ none: "Do not display invited users"
+ invitee_status:
+ unknown: "Undecided?"
+ going: "✓ Going"
+ not_going: "× Not Going"
+ interested: "? Interested"
+ post_event_status:
+ standalone:
+ title: Standalone
+ description: "A standalone event can't be joined."
+ public:
+ title: Public
+ description: "A public event can be joined by anyone."
+ private:
+ title: Private
+ description: "A private event can only be joined by invited users."
+ post_ui:
+ show_all: show all
+ add_to_calendar: add to calendar
+ send_pm_to_creator: contact
+ post-event-invitees-modal:
+ title: "List of invited users"
+ filter_placeholder: "Filter invited users"
+ ui_builder:
+ create_event_title: Create Event
+ update_event_title: Update Event
+ confirm_delete: Are you sure you want to delete this event?
+ create: Create
+ update: Save
+ attach: Create event
+ name:
+ label: Event name
+ placeholder: Optional, defaults to topic title
+ invitees:
+ label: Invited users/groups
+ status:
+ label: Status
+ display_invitees:
+ label: Display invited users
+ upcoming_events:
+ title: Upcoming events
group_timezones:
search: "Search..."
group_availability: "%{group} availability"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 59c3d3a4..61f75adf 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1,6 +1,7 @@
en:
site_settings:
calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic."
+ events_enabled: "Enables users to create events on a topic."
holiday_calendar_topic_id: "Topic ID of staffs holiday / absence calendar."
delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion."
all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00"
@@ -13,7 +14,12 @@ en:
working_day_end_hour: "End time of the working day hours."
close_to_working_day_hours_extension: "Set extension time in working day hours to highlight the timezones."
discourse_calendar:
+ invite_user_notification: "%{username} invited you to: %{description}"
calendar_must_be_in_first_post: "Calendar tag can only be used in first post of a topic."
more_than_one_calendar: "You can’t have more than one calendar in a post."
more_than_two_dates: "A post of a calendar topic can’t contain more than two dates."
event_expired: "Event expired"
+ post_event:
+ errors:
+ raw_invitees_length: "An event is limited to %{count} users/groups"
+ ends_at_before_starts_at: "An event can't end before it starts"
diff --git a/config/settings.yml b/config/settings.yml
index ae2ac0e0..dfa1b6ef 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -2,6 +2,9 @@ plugins:
calendar_enabled:
default: false
client: true
+ events_enabled:
+ default: true
+ client: true
holiday_calendar_topic_id:
default: ""
client: true
@@ -40,3 +43,7 @@ plugins:
close_to_working_day_hours_extension:
default: 2
client: true
+ displayed_invitees_limit:
+ default: 10
+ client: false
+ max: 25
diff --git a/db/migrate/20201303000001_create_post_events_table.rb b/db/migrate/20201303000001_create_post_events_table.rb
new file mode 100644
index 00000000..1f3c758c
--- /dev/null
+++ b/db/migrate/20201303000001_create_post_events_table.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class CreatePostEventsTable < ActiveRecord::Migration[5.2]
+ def up
+ create_table :discourse_calendar_post_events, id: false do |t|
+ t.bigint :id, null: false, primary_key: true
+ t.integer :status, default: 0, null: false
+ t.integer :display_invitees, default: 0, null: false
+ t.datetime :starts_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
+ t.datetime :ends_at
+ t.datetime :deleted_at
+ t.string :raw_invitees, array: true
+ t.string :name
+ end
+ end
+
+ def down
+ drop_table :discourse_calendar_post_events
+ end
+end
diff --git a/db/migrate/20201303000002_create_invitees_table.rb b/db/migrate/20201303000002_create_invitees_table.rb
new file mode 100644
index 00000000..cb3432be
--- /dev/null
+++ b/db/migrate/20201303000002_create_invitees_table.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreateInviteesTable < ActiveRecord::Migration[5.2]
+ def up
+ create_table :discourse_calendar_invitees do |t|
+ t.integer :post_id, null: false
+ t.integer :user_id, null: false
+ t.integer :status
+ t.timestamps null: false
+ t.boolean :notified, null: false, default: false
+ end
+
+ add_index :discourse_calendar_invitees, [:post_id, :user_id], unique: true
+ end
+
+ def down
+ drop_table :discourse_calendar_invitees
+ end
+end
diff --git a/lib/time_sniffer.rb b/lib/time_sniffer.rb
new file mode 100644
index 00000000..75e98da9
--- /dev/null
+++ b/lib/time_sniffer.rb
@@ -0,0 +1,357 @@
+# frozen_string_literal: true
+
+class TimeSniffer
+ Interval = Struct.new(:from, :to)
+ Event = Struct.new(:at)
+
+ Context = Struct.new(:at, :timezone, :date_order)
+
+ class SniffedTime
+ attr_reader :year
+ attr_reader :month
+ attr_reader :day
+ attr_reader :hours
+ attr_reader :minutes
+ attr_reader :seconds
+ attr_reader :zone
+
+ def initialize(year:, month:, day:, hours: 0, minutes: 0, seconds: 0, zone:)
+ @year = year
+ @month = month
+ @day = day
+ @hours = hours
+ @minutes = minutes
+ @seconds = seconds
+ @zone = zone
+ end
+
+ def self.from_datetime(obj, zone)
+ new(
+ year: obj.year,
+ month: obj.month,
+ day: obj.day,
+ hours: obj.hour,
+ minutes: obj.minute,
+ seconds: obj.second,
+ zone: zone
+ )
+ end
+
+ def to_time
+ Time.use_zone(self.zone) do
+ Time.zone.parse("#{self.year}-#{self.month}-#{self.day} #{self.hours}:#{self.minutes}:#{self.seconds}")
+ end
+ end
+
+ def with(**args)
+ SniffedTime.new(**to_hash.merge(args))
+ end
+
+ def to_hash
+ {
+ year: self.year,
+ month: self.month,
+ day: self.day,
+ hours: self.hours,
+ minutes: self.minutes,
+ seconds: self.seconds,
+ zone: self.zone,
+ }
+ end
+
+ def ==(other)
+ return false unless other.kind_of?(SniffedTime)
+ return false if @year != other.year
+ return false if @month != other.month
+ return false if @day != other.day
+ return false if @hours != other.hours
+ return false if @minutes != other.minutes
+ return false if @seconds != other.seconds
+ return false if @zone != other.zone
+ true
+ end
+ end
+
+ class << self
+ def matchers
+ @matchers ||= {}
+ end
+
+ def matcher(name, regex, &blk)
+ matchers[name] = {
+ regex: regex,
+ blk: blk,
+ }
+ end
+ end
+
+ class Parser
+ UTC_REGEX = / ?(Z|UTC)/
+
+ def initialize(input, context)
+ @input = input
+ @context = context
+ @offset = 0
+ end
+
+ def parse_timezone
+ m = input_from_offset.match(UTC_REGEX)
+ if m && m.offset(0)[0] == 0
+ self.offset += m.offset(0)[1]
+ "UTC"
+ end
+ end
+
+ def parse_space
+ if input[offset] == ' '
+ self.offset += 1
+ true
+ else
+ false
+ end
+ end
+
+ def parse_time(relative_to, immediate:)
+ time, start_offset, stop_offset = peek_time(relative_to)
+ if time && (!immediate || start_offset == 0)
+ self.offset += stop_offset
+ time
+ end
+ end
+
+ def parse_date
+ date_match = DATE_REGEX.match(input_from_offset)
+ if date_match
+ day, month =
+ case @context.date_order
+ when :us
+ [date_match[2], date_match[1]]
+ when :sane
+ [date_match[1], date_match[2]]
+ end
+
+ year = date_match[3]
+ year =
+ case year.size
+ when 2
+ century = @context.at.year - (@context.at.year % 100)
+ last_century = century - 100
+
+ choices = [
+ century + year.to_i,
+ last_century + year.to_i,
+ ]
+
+ choices.sort_by { |x|
+ (@context.at.year - x).abs
+ }[0]
+ when 4
+ year.to_i
+ end
+
+ result =
+ SniffedTime.new(
+ year: year,
+ month: month.to_i,
+ day: day.to_i,
+ zone: @context.timezone,
+ )
+
+ self.offset += date_match.offset(0)[1]
+ result
+ end
+ end
+
+ def parse_time_with_timezone(relative_to, immediate:)
+ result = parse_time(relative_to, immediate: immediate)
+ if result
+ zone = parse_timezone
+
+ if zone
+ result = result.with(zone: zone)
+ end
+
+ result
+ end
+ end
+
+ def parse_date_time(relative_to)
+ date = parse_date
+ if date
+ if parse_space
+ datetime = parse_time_with_timezone(date, immediate: true)
+ if datetime
+ [false, datetime]
+ else
+ [true, date]
+ end
+ else
+ [true, date]
+ end
+ elsif relative_to
+ datetime = parse_time_with_timezone(relative_to, immediate: false)
+ if datetime
+ [false, datetime]
+ else
+ [true, nil]
+ end
+ end
+ end
+
+ def parse_range
+ if x = parse_date_time(nil)
+ from_is_date, from = x
+ to_is_date, to = parse_date_time(from)
+
+ if to
+ if to_is_date
+ Interval.new(from.to_time, to.to_time + 1.day)
+ else
+ Interval.new(from.to_time, to.to_time)
+ end
+ else
+ if from_is_date
+ Interval.new(from.to_time, from.to_time + 1.day)
+ else
+ Event.new(from.to_time)
+ end
+ end
+ end
+ end
+
+ def input_from_offset
+ self.input[self.offset..-1]
+ end
+
+ def peek_time(relative_to)
+ m = self.input_from_offset.match(TIME_REGEX)
+ if m
+ parsed =
+ relative_to.with(
+ hours: m[1].to_i,
+ minutes: m[2].to_i,
+ seconds: 0,
+ zone: @context.timezone,
+ )
+
+ [parsed, *m.offset(0)]
+ end
+ end
+
+ attr_reader :input
+ attr_accessor :offset
+ end
+
+ matcher(:yesterday, /yesterday/) do |m|
+ today = at.to_date
+ yesterday = today - 1
+
+ Interval.new(
+ SniffedTime
+ .from_datetime(yesterday.to_datetime, timezone)
+ .to_time,
+ SniffedTime
+ .from_datetime(today.to_datetime, timezone)
+ .to_time,
+ )
+ end
+
+ matcher(:tomorrow, /tomorrow/i) do |_|
+ tomorrow = at.to_date + 1
+ the_day_after_tomorrow = tomorrow + 1
+
+ Interval.new(
+ SniffedTime
+ .from_datetime(tomorrow.to_datetime, timezone)
+ .to_time,
+ SniffedTime
+ .from_datetime(the_day_after_tomorrow.to_datetime, timezone)
+ .to_time,
+ )
+ end
+
+ TIME_REGEX = /(\d{1,2}):(\d{2})/
+
+ matcher(:time, TIME_REGEX) do |m|
+ times = input.scan(TIME_REGEX).to_a
+ from, to = times[0..2]
+ if to
+ Interval.new(
+ SniffedTime.new(
+ year: at.year,
+ month: at.month,
+ day: at.day,
+ hours: from[0].to_i,
+ minutes: from[1].to_i,
+ seconds: 0,
+ zone: timezone,
+ ).to_time,
+ SniffedTime.new(
+ year: at.year,
+ month: at.month,
+ day: at.day,
+ hours: to[0].to_i,
+ minutes: to[1].to_i,
+ seconds: 0,
+ zone: timezone,
+ ).to_time,
+ )
+ else
+ Event.new(
+ SniffedTime.new(
+ year: at.year,
+ month: at.month,
+ day: at.day,
+ hours: from[0].to_i,
+ minutes: from[1].to_i,
+ seconds: 0,
+ zone: timezone,
+ ).to_time
+ )
+ end
+ end
+
+ DATE_SEPARATOR = /[-\/]/
+ DATE_REGEX = /(\d{1,2})#{DATE_SEPARATOR}(\d{1,2})#{DATE_SEPARATOR}(\d{2,4})/
+
+ matcher(:date, DATE_REGEX) do |m|
+ Parser.new(input, @context).parse_range
+ end
+
+ def initialize(input, at: DateTime.now, timezone:, date_order:, matchers:, raise_errors: false)
+ @input = input
+ @at = at
+ @timezone = timezone
+ @date_order = date_order
+ @context = Context.new(@at, @timezone, @date_order)
+ @matchers = matchers
+ @raise_errors = raise_errors
+ end
+
+ def sniff
+ @matchers.each do |matcher_name|
+ matcher = self.class.matchers[matcher_name]
+ regex, blk = matcher.values_at(:regex, :blk)
+
+ match = regex.match(@input)
+ if match
+ begin
+ result = instance_exec(match, &blk)
+ rescue Exception => e
+ raise if @raise_errors
+ else
+ return result if result
+ end
+ end
+ end
+
+ nil
+ end
+
+ private
+
+ attr_reader :input
+ attr_reader :at
+ attr_reader :timezone
+ attr_reader :date_order
+end
diff --git a/plugin.rb b/plugin.rb
index 8f36ded0..5073d980 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -3,7 +3,7 @@
# name: discourse-calendar
# about: Display a calendar in the first post of a topic
# version: 0.2
-# author: Joffrey Jaffeux
+# author: Daniel Waterworth, Joffrey Jaffeux
# url: https://github.com/discourse/discourse-calendar
gem "holidays", "8.0.0", require: false
@@ -14,10 +14,15 @@ enabled_site_setting :calendar_enabled
register_asset "stylesheets/vendor/fullcalendar.min.css"
register_asset "stylesheets/common/discourse-calendar.scss"
+register_asset "stylesheets/common/post-event.scss"
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop
+register_svg_icon "fas fa-calendar-day"
+register_svg_icon "fas fa-question"
+register_svg_icon "fas fa-clock"
after_initialize do
+
module ::DiscourseCalendar
PLUGIN_NAME ||= "discourse-calendar"
@@ -43,18 +48,32 @@ after_initialize do
def self.users_on_holiday=(usernames)
PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames)
end
+
+ class Engine < ::Rails::Engine
+ engine_name PLUGIN_NAME
+ isolate_namespace DiscourseCalendar
+ end
end
[
"../app/models/calendar_event.rb",
+ "../app/models/guardian.rb",
"../app/serializers/user_timezone_serializer.rb",
+ "../app/controllers/discourse_calendar/invitees_controller.rb",
+ "../app/controllers/discourse_calendar/post_events_controller.rb",
+ "../app/controllers/discourse_calendar/upcoming_events_controller.rb",
+ "../app/models/discourse_calendar/post_event.rb",
+ "../app/models/discourse_calendar/invitee.rb",
+ "../app/serializers/discourse_calendar/invitee_serializer.rb",
+ "../app/serializers/discourse_calendar/post_event_serializer.rb",
"../jobs/scheduled/create_holiday_events.rb",
"../jobs/scheduled/destroy_past_events.rb",
"../jobs/scheduled/update_holiday_usernames.rb",
"../lib/calendar_validator.rb",
"../lib/calendar.rb",
"../lib/event_validator.rb",
- "../lib/group_timezones.rb"
+ "../lib/group_timezones.rb",
+ "../lib/time_sniffer.rb",
].each { |path| load File.expand_path(path, __FILE__) }
register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string)
@@ -198,4 +217,102 @@ after_initialize do
add_to_serializer(:site, :include_users_on_holiday?) do
scope.is_staff?
end
+
+ require 'post'
+ class ::Post
+ has_one :post_event,
+ dependent: :destroy,
+ class_name: 'DiscourseCalendar::PostEvent',
+ foreign_key: :id
+ end
+
+ add_to_serializer(:post, :post_event) do
+ DiscourseCalendar::PostEventSerializer.new(object.post_event, scope: scope, root: false)
+ end
+
+ reloadable_patch do |plugin|
+ add_to_serializer(:post, :include_post_event?) do
+ plugin.enabled?
+ end
+ end
+
+ Discourse::Application.routes.append do
+ mount ::DiscourseCalendar::Engine, at: '/'
+ end
+
+ DiscourseCalendar::Engine.routes.draw do
+ get '/discourse-calendar/post-events/:id' => 'post_events#show'
+ delete '/discourse-calendar/post-events/:id' => 'post_events#destroy'
+ get '/discourse-calendar/post-events' => 'post_events#index'
+ post '/discourse-calendar/post-events' => 'post_events#create'
+ put '/discourse-calendar/post-events/:id' => 'post_events#update'
+ put '/discourse-calendar/invitees/:id' => 'invitees#update'
+ post '/discourse-calendar/invitees' => 'invitees#create'
+ get '/discourse-calendar/invitees' => 'invitees#index'
+ get '/upcoming-events' => 'upcoming_events#index'
+ end
+
+ DiscourseEvent.on(:post_destroyed) do |post|
+ if post.post_event
+ post.post_event.update!(deleted_at: Time.now)
+ end
+ end
+
+ DiscourseEvent.on(:post_recovered) do |post|
+ if post.post_event
+ post.post_event.update!(deleted_at: nil)
+ end
+ end
+
+ DiscourseEvent.on(:post_edited) do |post, topic_changed|
+ if post.post_event && post.is_first_post? && post.topic && topic_changed && post.topic != Archetype.private_message
+ time_range = extract_time_range(post.topic, post.user)
+
+ case time_range
+ when TimeSniffer::Interval
+ post.post_event.update!(
+ starts_at: time_range.from.to_time.utc,
+ ends_at: time_range.to.to_time.utc,
+ )
+ when TimeSniffer::Event
+ post.post_event.update!(
+ starts_at: time_range.at.to_time.utc
+ )
+ end
+
+ post.post_event.publish_update!
+ end
+ end
+
+ def extract_time_range(topic, user)
+ TimeSniffer.new(
+ topic.title,
+ at: topic.created_at,
+ timezone: user.user_option.timezone || 'UTC',
+ date_order: :sane,
+ matchers: [:tomorrow, :date, :time],
+ ).sniff
+ end
+
+ DiscourseEvent.on(:topic_created) do |topic, args, user|
+ if topic.archetype != Archetype.private_message
+ time_range = extract_time_range(topic, user)
+
+ case time_range
+ when TimeSniffer::Interval
+ DiscourseCalendar::PostEvent.create!(
+ id: topic.first_post.id,
+ starts_at: time_range.from.to_time.utc,
+ ends_at: time_range.to.to_time.utc,
+ status: DiscourseCalendar::PostEvent.statuses[:standalone]
+ )
+ when TimeSniffer::Event
+ DiscourseCalendar::PostEvent.create!(
+ id: topic.first_post.id,
+ starts_at: time_range.at.to_time.utc,
+ status: DiscourseCalendar::PostEvent.statuses[:standalone]
+ )
+ end
+ end
+ end
end
diff --git a/spec/acceptance/post_spec.rb b/spec/acceptance/post_spec.rb
new file mode 100644
index 00000000..7473c285
--- /dev/null
+++ b/spec/acceptance/post_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+require_relative '../fabricators/post_event_fabricator'
+
+describe Post do
+ PostEvent ||= DiscourseCalendar::PostEvent
+
+ fab!(:user) { Fabricate(:user) }
+ fab!(:topic) { Fabricate(:topic, user: user) }
+ fab!(:post1) { Fabricate(:post, topic: topic) }
+ fab!(:post_event) { Fabricate(:post_event, post: post1) }
+
+ before do
+ freeze_time
+ SiteSetting.queue_jobs = false
+ end
+
+ context 'when a post with an event is destroyed' do
+ it 'sets deleted_at on the post_event' do
+ expect(post_event.deleted_at).to be_nil
+
+ PostDestroyer.new(user, post_event.post).destroy
+ post_event.reload
+
+ expect(post_event.deleted_at).to eq(Time.now)
+ end
+ end
+
+ context 'when a post with an event is recovered' do
+ it 'nullifies deleted_at on the post_event' do
+ PostDestroyer.new(user, post_event.post).destroy
+ post_event.reload
+
+ expect(post_event.deleted_at).to eq(Time.now)
+
+ PostDestroyer.new(user, post_event.post).recover
+ post_event.reload
+
+ expect(post_event.deleted_at).to be_nil
+ end
+ end
+end
diff --git a/spec/acceptance/topic_spec.rb b/spec/acceptance/topic_spec.rb
new file mode 100644
index 00000000..9eb4c3f5
--- /dev/null
+++ b/spec/acceptance/topic_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe Topic do
+ PostEvent ||= DiscourseCalendar::PostEvent
+
+ before do
+ freeze_time
+ SiteSetting.queue_jobs = false
+ end
+
+ fab!(:user) { Fabricate(:user) }
+
+ context 'when a topic is created' do
+ context 'with a date' do
+ it 'creates a post event' do
+ post_with_date = PostCreator.create!(
+ user,
+ title: 'Let’s buy a boat with me tomorrow',
+ raw: 'The boat market is quite active lately.'
+ )
+
+ post_event = PostEvent.find(post_with_date.id)
+ expect(post_event).to be_present
+ expect(post_event.starts_at).to eq(post_with_date.topic.created_at.tomorrow.beginning_of_day)
+ expect(post_event.status).to eq(PostEvent.statuses[:standalone])
+ end
+ end
+ end
+end
diff --git a/spec/fabricators/post_event_fabricator.rb b/spec/fabricators/post_event_fabricator.rb
new file mode 100644
index 00000000..637f8f05
--- /dev/null
+++ b/spec/fabricators/post_event_fabricator.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+Fabricator(:post_event, from: 'DiscourseCalendar::PostEvent') do
+ post { |attrs| attrs[:post] }
+
+ id { |attrs| attrs[:post].id }
+
+ status { |attrs|
+ attrs[:status] ?
+ DiscourseCalendar::PostEvent.statuses[attrs[:status]] :
+ DiscourseCalendar::PostEvent.statuses[:public]
+ }
+ starts_at { |attrs| attrs[:starts_at] || 1.day.from_now.iso8601 }
+ ends_at { |attrs| attrs[:ends_at] }
+end
diff --git a/spec/lib/time_sniffer_spec.rb b/spec/lib/time_sniffer_spec.rb
new file mode 100644
index 00000000..1f81a0f4
--- /dev/null
+++ b/spec/lib/time_sniffer_spec.rb
@@ -0,0 +1,211 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe TimeSniffer do
+ before do
+ freeze_time DateTime.parse('2020-04-24 14:10')
+ end
+
+ let(:default_context) {
+ {
+ at: DateTime.parse('2020-1-20 00:00:00'),
+ timezone: 'EST',
+ date_order: :sane,
+ matchers: [:tomorrow, :date, :time],
+ raise_errors: true,
+ }
+ }
+
+ define_method(:expect_parsed_as_interval) do |str, from:, to:, context: default_context|
+ Time.use_zone(context[:timezone]) do
+ expect(TimeSniffer.new(str, **context).sniff).to(
+ eq(TimeSniffer::Interval.new(Time.zone.parse(from), Time.zone.parse(to)))
+ )
+ end
+ end
+
+ define_method(:expect_parsed_as_event) do |str, at, context: default_context|
+ Time.use_zone(context[:timezone]) do
+ expect(TimeSniffer.new(str, **context).sniff).to(
+ eq(TimeSniffer::Event.new(Time.zone.parse(at)))
+ )
+ end
+ end
+
+ define_method(:expect_parsed_as_nil) do |str, context: default_context|
+ expect(TimeSniffer.new(str, **context).sniff).to(
+ eq(nil)
+ )
+ end
+
+ it "should support tomorrow with a timezone" do
+ expect_parsed_as_interval(
+ "tomorrow",
+ from: "2020-1-21 EST",
+ to: "2020-1-22 EST",
+ )
+ end
+
+ it "should support Tomorrow" do
+ expect_parsed_as_interval(
+ "Tomorrow",
+ from: "2020-1-21",
+ to: "2020-1-22",
+ )
+ end
+
+ it "should support 14:00" do
+ expect_parsed_as_event("14:00", "2020-1-20 14:00 EST")
+ end
+
+ it "should support 14:24" do
+ expect_parsed_as_event("14:24", "2020-1-20 14:24 EST")
+ end
+
+ it "should support 15:00 with emojis" do
+ expect_parsed_as_event("😊😊😊😊15:00😊😊😊😊", "2020-1-20 15:00 EST")
+ end
+
+ it "should support 14:00 - 15:00" do
+ expect_parsed_as_interval(
+ "14:00 - 15:00",
+ from: "2020-1-20 14:00 EST",
+ to: "2020-1-20 15:00 EST",
+ )
+ end
+
+ it "should support too many times" do
+ expect_parsed_as_interval(
+ "14:00 - 15:00 asotuhosthu 16:00",
+ from: "2020-1-20 14:00 EST",
+ to: "2020-1-20 15:00 EST",
+ )
+ end
+
+ it "should support too many times" do
+ expect_parsed_as_interval(
+ "14:00 - 15:00 asotuhosthu 16:00",
+ from: "2020-1-20 14:00 EST",
+ to: "2020-1-20 15:00 EST",
+ )
+ end
+
+ it "should support a date" do
+ expect_parsed_as_interval(
+ "31/3/25",
+ from: "2025-3-31 00:00 EST",
+ to: "2025-4-1 00:00 EST",
+ )
+ end
+
+ it "should support a date in the past century" do
+ expect_parsed_as_interval(
+ "31/3/75",
+ from: "1975-3-31 00:00 EST",
+ to: "1975-4-1 00:00 EST",
+ )
+ end
+
+ it "should support a date with a year with 4 digits" do
+ expect_parsed_as_interval(
+ "31/3/2021",
+ from: "2021-3-31 00:00 EST",
+ to: "2021-4-1 00:00 EST",
+ )
+ end
+
+ it "should support a date with hyphens" do
+ expect_parsed_as_interval(
+ "31-3-25",
+ from: "2025-3-31 00:00 EST",
+ to: "2025-4-1 00:00 EST",
+ )
+ end
+
+ it "should support a date with a time" do
+ expect_parsed_as_event(
+ "31-3-25 08:00",
+ "2025-3-31 08:00 EST",
+ )
+ end
+
+ it "should support a date with a time with non-zero minutes" do
+ expect_parsed_as_event(
+ "31-3-25 08:45",
+ "2025-3-31 08:45 EST",
+ )
+ end
+
+ it "should support a date with a time and a timezone" do
+ expect_parsed_as_event(
+ "31-3-25 08:00 UTC",
+ "2025-3-31 08:00:00 UTC",
+ context: default_context.merge(timezone: 'EST'),
+ )
+ end
+
+ it "should support a date with a time and a timezone" do
+ expect_parsed_as_event(
+ "31-3-25 08:00UTC",
+ "2025-3-31 08:00:00 UTC",
+ context: default_context.merge(timezone: 'EST'),
+ )
+ end
+
+ it "should support a date with a time and a timezone" do
+ expect_parsed_as_event(
+ "31-3-25 08:00Z",
+ "2025-3-31 08:00:00 UTC",
+ context: default_context.merge(timezone: 'EST'),
+ )
+ end
+
+ it "should support a date range" do
+ expect_parsed_as_interval(
+ "25/2/21 - 10/3/22",
+ from: "2021-2-25 00:00 EST",
+ to: "2022-3-11 00:00 EST",
+ )
+ end
+
+ it "should support a date range" do
+ expect_parsed_as_interval(
+ "25/2/21 - 10/3/22 14:00",
+ from: "2021-2-25 00:00 EST",
+ to: "2022-3-10 14:00 EST",
+ )
+ end
+
+ it "should support a date range with two times" do
+ expect_parsed_as_interval(
+ "25/2/21 9:00 - 10/3/22 14:00",
+ from: "2021-2-25 09:00 EST",
+ to: "2022-3-10 14:00 EST",
+ )
+ end
+
+ it "should support a date range with two times where the second is relative to the first" do
+ expect_parsed_as_interval(
+ "25/2/21 9:00 - 14:00",
+ from: "2021-2-25 09:00 EST",
+ to: "2021-2-25 14:00 EST",
+ )
+ end
+
+ it "should correctly handle timezones in future" do
+ expect_parsed_as_event(
+ "24/06/2020 14:23",
+ "2020-06-24 14:23 CEST",
+ context: default_context.merge(timezone: 'Europe/Paris'),
+ )
+ end
+
+ it "should not find a time in a random number" do
+ expect_parsed_as_nil("1500")
+ end
+
+ it "should not find a time in random numbers and an emoji" do
+ expect_parsed_as_nil("15😊00")
+ end
+end
diff --git a/spec/requests/invitees_controller_spec.rb b/spec/requests/invitees_controller_spec.rb
new file mode 100644
index 00000000..82a0fa61
--- /dev/null
+++ b/spec/requests/invitees_controller_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require_relative '../fabricators/post_event_fabricator'
+
+module DiscourseCalendar
+ describe InviteesController do
+ fab!(:user) { Fabricate(:user, admin: true) }
+ fab!(:topic) { Fabricate(:topic, user: user) }
+ fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
+
+ before do
+ SiteSetting.queue_jobs = false
+ sign_in(user)
+ end
+
+ context 'when a post event exists' do
+ context 'when an invitee exists' do
+ fab!(:invitee1) { Fabricate(:user) }
+ fab!(:post_event) {
+ pe = Fabricate(:post_event, post: post1)
+ pe.create_invitees([{
+ user_id: invitee1.id,
+ status: Invitee.statuses[:going]
+ }])
+ pe
+ }
+
+ it 'updates its status' do
+ invitee = post_event.invitees.first
+
+ expect(invitee.status).to eq(0)
+
+ put "/discourse-calendar/invitees/#{invitee.id}.json", params: {
+ invitee: {
+ status: "interested"
+ }
+ }
+
+ invitee.reload
+
+ expect(invitee.status).to eq(1)
+ end
+ end
+
+ context 'when an invitee doesn’t exist' do
+ fab!(:post_event) { Fabricate(:post_event, post: post1) }
+
+ it 'creates an invitee' do
+ post "/discourse-calendar/invitees.json", params: {
+ invitee: {
+ user_id: user.id,
+ post_id: post_event.id,
+ status: "not_going",
+ }
+ }
+
+ expect(Invitee).to exist(
+ post_id: post_event.id,
+ user_id: user.id,
+ status: 2,
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/post_events_controller_spec.rb b/spec/requests/post_events_controller_spec.rb
new file mode 100644
index 00000000..9726cf32
--- /dev/null
+++ b/spec/requests/post_events_controller_spec.rb
@@ -0,0 +1,328 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+require_relative '../fabricators/post_event_fabricator'
+
+module DiscourseCalendar
+ describe PostEventsController do
+ fab!(:user) { Fabricate(:user, admin: true) }
+ fab!(:topic) { Fabricate(:topic, user: user) }
+ fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
+ fab!(:invitee1) { Fabricate(:user) }
+ fab!(:invitee2) { Fabricate(:user) }
+
+ before do
+ SiteSetting.queue_jobs = false
+ SiteSetting.displayed_invitees_limit = 3
+ end
+
+ context 'when a post exists' do
+ fab!(:invitee3) { Fabricate(:user) }
+ fab!(:invitee4) { Fabricate(:user) }
+ fab!(:invitee5) { Fabricate(:user) }
+ fab!(:group) {
+ Fabricate(:group).tap do |g|
+ g.add(invitee2)
+ g.add(invitee3)
+ g.save!
+ end
+ }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'creates a post event' do
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ expect(json['post_event']['id']).to eq(post1.id)
+ expect(PostEvent).to exist(id: post1.id)
+ end
+
+ it 'accepts user and group invitees' do
+ invitees = [invitee1.username, group.name]
+
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id,
+ raw_invitees: invitees,
+ status: PostEvent.statuses[:private],
+ display_invitees: PostEvent.display_invitees_options[:everyone]
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ sample_invitees = json['post_event']['sample_invitees']
+ expect(sample_invitees.map { |i| i['user']['id'] }).to match_array([user.id, invitee1.id, group.group_users.first.user.id])
+ raw_invitees = json['post_event']['raw_invitees']
+ expect(raw_invitees).to match_array(invitees)
+ end
+
+ it 'accepts one user invitee' do
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id,
+ status: PostEvent.statuses[:private],
+ raw_invitees: [invitee1.username],
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ sample_invitees = json['post_event']['sample_invitees']
+ expect(sample_invitees[0]['user']['username']).to eq(user.username)
+ expect(sample_invitees[1]['user']['username']).to eq(invitee1.username)
+ end
+
+ it 'accepts one group invitee' do
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id,
+ status: PostEvent.statuses[:private],
+ raw_invitees: [group.name],
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ sample_invitees = json['post_event']['sample_invitees']
+ expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username] + group.group_users.map(&:user).map(&:username))
+ end
+
+ it 'accepts no invitee' do
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id,
+ raw_invitees: [],
+ status: PostEvent.statuses[:private],
+ display_invitees: PostEvent.display_invitees_options[:everyone]
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ sample_invitees = json['post_event']['sample_invitees']
+ expect(sample_invitees.count).to eq(1)
+ expect(sample_invitees[0]['user']['username']).to eq(user.username)
+ end
+
+ it 'limits displayed invitees' do
+ post '/discourse-calendar/post-events.json', params: {
+ post_event: {
+ id: post1.id,
+ status: PostEvent.statuses[:private],
+ raw_invitees: [
+ invitee1.username,
+ invitee2.username,
+ invitee3.username,
+ invitee4.username,
+ invitee5.username,
+ ],
+ }
+ }
+
+ expect(response.status).to eq(200)
+ json = ::JSON.parse(response.body)
+ sample_invitees = json['post_event']['sample_invitees']
+ expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username, invitee1.username, invitee2.username])
+ end
+
+ context 'when a post_event exists' do
+ fab!(:post_event) { Fabricate(:post_event, post: post1) }
+
+ context 'when we update the post_event' do
+ context 'when status changes from standalone to private' do
+ it 'changes the status, raw_invitees and invitees' do
+ post_event.update!(status: PostEvent.statuses[:standalone])
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:private].to_s,
+ raw_invitees: [invitee1.username]
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:private])
+ expect(post_event.raw_invitees).to eq([invitee1.username])
+ expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id])
+ end
+ end
+
+ context 'when status changes from standalone to public' do
+ it 'changes the status' do
+ post_event.update!(status: PostEvent.statuses[:standalone])
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:public].to_s
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:public])
+ expect(post_event.raw_invitees).to eq([])
+ expect(post_event.invitees).to eq([])
+ end
+ end
+
+ context 'when status changes from private to standalone' do
+ it 'changes the status' do
+ post_event.update!(
+ status: PostEvent.statuses[:private],
+ raw_invitees: [invitee1.username]
+ )
+ post_event.fill_invitees!
+
+ post_event.reload
+
+ expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
+ expect(post_event.raw_invitees).to eq([invitee1.username])
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:standalone].to_s
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:standalone])
+ expect(post_event.raw_invitees).to eq([])
+ expect(post_event.invitees).to eq([])
+ end
+ end
+
+ context 'when status changes from private to public' do
+ it 'changes the status, removes raw_invitees and keeps invitees' do
+ post_event.update!(
+ status: PostEvent.statuses[:private],
+ raw_invitees: [invitee1.username]
+ )
+ post_event.fill_invitees!
+
+ post_event.reload
+
+ expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
+ expect(post_event.raw_invitees).to eq([invitee1.username])
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:public].to_s
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:public])
+ expect(post_event.raw_invitees).to eq([])
+ expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
+ end
+ end
+
+ context 'when status changes from public to private' do
+ it 'changes the status, removes raw_invitees and keeps invitees' do
+ post_event.update!(status: PostEvent.statuses[:public])
+ post_event.create_invitees([
+ { user_id: invitee1.id },
+ { user_id: invitee2.id },
+ ])
+ post_event.reload
+
+ expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id, invitee2.id])
+ expect(post_event.raw_invitees).to eq(nil)
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:private].to_s,
+ raw_invitees: [invitee1.username]
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:private])
+ expect(post_event.raw_invitees).to eq([invitee1.username])
+ expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
+ end
+ end
+
+ context 'when status changes from public to standalone' do
+ it 'changes the status, removes invitees' do
+ post_event.update!(
+ status: PostEvent.statuses[:public]
+ )
+ post_event.create_invitees([ { user_id: invitee1.id } ])
+ post_event.reload
+
+ expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
+ expect(post_event.raw_invitees).to eq(nil)
+
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:standalone].to_s
+ }
+ }
+
+ post_event.reload
+
+ expect(post_event.status).to eq(PostEvent.statuses[:standalone])
+ expect(post_event.raw_invitees).to eq([])
+ expect(post_event.invitees).to eq([])
+ end
+ end
+ end
+
+ context 'acting user has created the post_event' do
+ it 'destroys a post_event' do
+ expect(post_event.persisted?).to be(true)
+
+ messages = MessageBus.track_publish do
+ delete "/discourse-calendar/post-events/#{post_event.id}.json"
+ end
+ expect(messages.count).to eq(1)
+ message = messages.first
+ expect(message.channel).to eq("/post-events/#{post_event.post.topic_id}")
+ expect(message.data[:id]).to eq(post_event.id)
+ expect(response.status).to eq(200)
+ expect(PostEvent).to_not exist(id: post_event.id)
+ end
+ end
+
+ context 'acting user has not created the post_event' do
+ fab!(:lurker) { Fabricate(:user) }
+
+ before do
+ sign_in(lurker)
+ end
+
+ it 'doesn’t destroy the post_event' do
+ expect(post_event.persisted?).to be(true)
+ delete "/discourse-calendar/post-events/#{post_event.id}.json"
+ expect(response.status).to eq(403)
+ expect(PostEvent).to exist(id: post_event.id)
+ end
+
+ it 'doesn’t update the post_event' do
+ put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
+ post_event: {
+ status: PostEvent.statuses[:public],
+ }
+ }
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+ end
+ end
+end