diff --git a/app/models/discourse_post_event/event.rb b/app/models/discourse_post_event/event.rb index ac792350..3f2cfbfe 100644 --- a/app/models/discourse_post_event/event.rb +++ b/app/models/discourse_post_event/event.rb @@ -3,18 +3,39 @@ module DiscoursePostEvent class Event < ActiveRecord::Base PUBLIC_GROUP = "trust_level_0" - + MIN_NAME_LENGTH = 5 + MAX_NAME_LENGTH = 255 self.table_name = "discourse_post_event_events" - self.ignored_columns = %w[starts_at ends_at] has_many :event_dates, dependent: :destroy + # this is a cross plugin dependency, only called if chat is enabled + belongs_to :chat_channel, class_name: "Chat::Channel" + has_many :invitees, foreign_key: :post_id, dependent: :delete_all + belongs_to :post, foreign_key: :id + + scope :visible, -> { where(deleted_at: nil) } + + after_commit :destroy_topic_custom_field, on: %i[destroy] + after_commit :create_or_update_event_date, on: %i[create update] + before_save :chat_channel_sync + + validate :raw_invitees_are_groups + validates :original_starts_at, presence: true + validates :name, + length: { + in: MIN_NAME_LENGTH..MAX_NAME_LENGTH, + }, + unless: ->(event) { event.name.blank? } + + validate :raw_invitees_length + validate :ends_before_start + validate :allowed_custom_fields def self.attributes_protected_by_default super - %w[id] end - after_commit :destroy_topic_custom_field, on: %i[destroy] def destroy_topic_custom_field if self.post && self.post.is_first_post? TopicCustomField.where( @@ -29,7 +50,6 @@ module DiscoursePostEvent end end - after_commit :create_or_update_event_date, on: %i[create update] def create_or_update_event_date starts_at_changed = saved_change_to_original_starts_at ends_at_changed = saved_change_to_original_ends_at @@ -84,11 +104,6 @@ module DiscoursePostEvent ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input } end - has_many :invitees, foreign_key: :post_id, dependent: :delete_all - belongs_to :post, foreign_key: :id - - scope :visible, -> { where(deleted_at: nil) } - def expired? (ends_at || starts_at.end_of_day) <= Time.now end @@ -103,8 +118,6 @@ module DiscoursePostEvent event_dates.order(:updated_at, :id).last&.ends_at end - validates :original_starts_at, presence: true - def on_going_event_invitees return [] if !self.ends_at && self.starts_at < Time.now @@ -117,15 +130,6 @@ module DiscoursePostEvent invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going]) end - MIN_NAME_LENGTH = 5 - MAX_NAME_LENGTH = 255 - validates :name, - length: { - in: MIN_NAME_LENGTH..MAX_NAME_LENGTH, - }, - unless: ->(event) { event.name.blank? } - - validate :raw_invitees_length def raw_invitees_length if self.raw_invitees && self.raw_invitees.length > 10 errors.add( @@ -135,7 +139,6 @@ module DiscoursePostEvent end end - validate :raw_invitees_are_groups def raw_invitees_are_groups if self.raw_invitees && User.select(:id).where(username: self.raw_invitees).limit(1).count > 0 errors.add( @@ -145,7 +148,6 @@ module DiscoursePostEvent end end - validate :ends_before_start def ends_before_start if self.original_starts_at && self.original_ends_at && self.original_starts_at >= self.original_ends_at @@ -156,7 +158,6 @@ module DiscoursePostEvent end end - validate :allowed_custom_fields def allowed_custom_fields allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|") self.custom_fields.each do |key, value| @@ -174,8 +175,12 @@ module DiscoursePostEvent attrs.map! do |attr| { post_id: self.id, created_at: timestamp, updated_at: timestamp }.merge(attr) end + result = self.invitees.insert_all!(attrs) - self.invitees.insert_all!(attrs) + # batch event does not call calleback + ChatChannelSync.sync(self) if chat_enabled? + + result end def notify_invitees!(predefined_attendance: false) @@ -309,6 +314,7 @@ module DiscoursePostEvent raw_invitees: event_params[:"allowed-groups"]&.split(","), minimal: event_params[:minimal], closed: event_params[:closed] || false, + chat_enabled: event_params[:"chat-enabled"]&.downcase == "true", } params[:custom_fields] = {} @@ -364,6 +370,15 @@ module DiscoursePostEvent self.publish_update! end + def chat_channel_sync + if self.chat_enabled && self.chat_channel_id.blank? && post.last_editor_id.present? + DiscoursePostEvent::ChatChannelSync.sync( + self, + guardian: Guardian.new(User.find_by(id: post.last_editor_id)), + ) + end + end + def calculate_next_date(start_date: nil) localized_start = start_date || original_starts_at.in_time_zone(timezone) @@ -408,4 +423,6 @@ end # timezone :string # minimal :boolean # closed :boolean default(FALSE), not null +# chat_enabled :boolean default(FALSE), not null +# chat_channel_id :bigint # diff --git a/app/models/discourse_post_event/invitee.rb b/app/models/discourse_post_event/invitee.rb index 621a587a..570d3865 100644 --- a/app/models/discourse_post_event/invitee.rb +++ b/app/models/discourse_post_event/invitee.rb @@ -10,9 +10,10 @@ module DiscoursePostEvent belongs_to :user default_scope { joins(:user).includes(:user).where("users.id IS NOT NULL") } - scope :with_status, ->(status) { where(status: Invitee.statuses[status]) } + after_commit :sync_chat_channel_members + def self.statuses @statuses ||= Enum.new(going: 0, interested: 1, not_going: 2) end @@ -45,6 +46,11 @@ module DiscoursePostEvent ) end + def sync_chat_channel_members + return if !self.event.chat_enabled? + ChatChannelSync.sync(self.event) + end + def update_topic_tracking! topic_id = self.event.post.topic.id user_id = self.user.id diff --git a/app/serializers/discourse_post_event/event_serializer.rb b/app/serializers/discourse_post_event/event_serializer.rb index bed944cf..110a48e9 100644 --- a/app/serializers/discourse_post_event/event_serializer.rb +++ b/app/serializers/discourse_post_event/event_serializer.rb @@ -30,6 +30,16 @@ module DiscoursePostEvent attributes :timezone attributes :url attributes :watching_invitee + attributes :chat_enabled + attributes :channel + + def channel + ::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:) + end + + def include_channel? + object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present? + end def can_act_on_discourse_post_event scope.can_act_on_discourse_post_event?(object) diff --git a/app/services/discourse_post_event/chat_channel_sync.rb b/app/services/discourse_post_event/chat_channel_sync.rb new file mode 100644 index 00000000..37f9320a --- /dev/null +++ b/app/services/discourse_post_event/chat_channel_sync.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# +module DiscoursePostEvent + class ChatChannelSync + def self.sync(event, guardian: nil) + return if !event.chat_enabled? + if !event.chat_channel_id && guardian&.can_create_chat_channel? + ensure_chat_channel!(event, guardian:) + end + sync_chat_channel_members!(event) if event.chat_channel_id + end + + def self.sync_chat_channel_members!(event) + missing_members_sql = <<~SQL + SELECT user_id + FROM discourse_post_event_invitees + WHERE post_id = :post_id + AND status in (:statuses) + AND user_id NOT IN ( + SELECT user_id + FROM user_chat_channel_memberships + WHERE chat_channel_id = :chat_channel_id + ) + SQL + + missing_user_ids = + DB.query_single( + missing_members_sql, + post_id: event.post.id, + statuses: [ + DiscoursePostEvent::Invitee.statuses[:going], + DiscoursePostEvent::Invitee.statuses[:interested], + ], + chat_channel_id: event.chat_channel_id, + ) + + if missing_user_ids.present? + ActiveRecord::Base.transaction do + missing_user_ids.each do |user_id| + event.chat_channel.user_chat_channel_memberships.create!( + user_id:, + chat_channel_id: event.chat_channel_id, + following: true, + ) + end + end + end + end + + def self.ensure_chat_channel!(event, guardian:) + name = event.name + + channel = nil + Chat::CreateCategoryChannel.call( + guardian:, + params: { + name:, + category_id: event.post.topic.category_id, + }, + ) do |result| + on_success { channel = result.channel } + on_failure { raise StandardError, result.inspect_steps } + end + + # event creator will be a member of the channel + event.chat_channel_id = channel.id + end + end +end diff --git a/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs b/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs new file mode 100644 index 00000000..90668d0d --- /dev/null +++ b/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs @@ -0,0 +1,15 @@ +import { LinkTo } from "@ember/routing"; +import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title"; + +const DiscoursePostEventChatChannel = ; + +export default DiscoursePostEventChatChannel; diff --git a/assets/javascripts/discourse/components/discourse-post-event/index.gjs b/assets/javascripts/discourse/components/discourse-post-event/index.gjs index 7e73f90d..80a460ea 100644 --- a/assets/javascripts/discourse/components/discourse-post-event/index.gjs +++ b/assets/javascripts/discourse/components/discourse-post-event/index.gjs @@ -7,6 +7,7 @@ import concatClass from "discourse/helpers/concat-class"; import icon from "discourse/helpers/d-icon"; import replaceEmoji from "discourse/helpers/replace-emoji"; import routeAction from "discourse/helpers/route-action"; +import ChatChannel from "./chat-channel"; import Creator from "./creator"; import Dates from "./dates"; import EventStatus from "./event-status"; @@ -124,10 +125,12 @@ export default class DiscoursePostEvent extends Component { Dates=(component Dates event=@event) Invitees=(component Invitees event=@event) Status=(component Status event=@event) + ChatChannel=(component ChatChannel event=@event) }} > + {{#if @event.canUpdateAttendance}} diff --git a/assets/javascripts/discourse/components/modal/post-event-builder.hbs b/assets/javascripts/discourse/components/modal/post-event-builder.hbs index 7ad0c879..31cebbfd 100644 --- a/assets/javascripts/discourse/components/modal/post-event-builder.hbs +++ b/assets/javascripts/discourse/components/modal/post-event-builder.hbs @@ -17,12 +17,14 @@ @outletArgs={{hash event=@model.event}} @connectorTagName="div" > - + + + + {{#if this.showChat}} + + + + {{/if}} + {{#if this.allowedCustomFields.length}} = 5; } + get showChat() { + // As of June 2025, chat channel creation is only available to admins and moderators + return ( + this.siteSettings.chat_enabled && + (this.currentUser.admin || this.currentUser.moderator) + ); + } + @action groupFinder(term) { return Group.findAll({ term, ignore_automatic: true }); diff --git a/assets/javascripts/discourse/lib/raw-event-helper.js b/assets/javascripts/discourse/lib/raw-event-helper.js index dfcc216d..6294eed4 100644 --- a/assets/javascripts/discourse/lib/raw-event-helper.js +++ b/assets/javascripts/discourse/lib/raw-event-helper.js @@ -33,6 +33,10 @@ export function buildParams(startsAt, endsAt, event, siteSettings) { params.minimal = "true"; } + if (event.chatEnabled) { + params.chatEnabled = "true"; + } + if (endsAt) { params.end = moment(endsAt).tz(eventTz).format("YYYY-MM-DD HH:mm"); } diff --git a/assets/javascripts/discourse/models/discourse-post-event-event.js b/assets/javascripts/discourse/models/discourse-post-event-event.js index 18832db9..6f8c5693 100644 --- a/assets/javascripts/discourse/models/discourse-post-event-event.js +++ b/assets/javascripts/discourse/models/discourse-post-event-event.js @@ -3,6 +3,7 @@ import EmberObject from "@ember/object"; import { TrackedArray } from "@ember-compat/tracked-built-ins"; import { bind } from "discourse/lib/decorators"; import User from "discourse/models/user"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import DiscoursePostEventEventStats from "./discourse-post-event-event-stats"; import DiscoursePostEventInvitee from "./discourse-post-event-invitee"; @@ -29,6 +30,7 @@ export default class DiscoursePostEventEvent { @tracked status; @tracked post; @tracked minimal; + @tracked chatEnabled; @tracked canUpdateAttendance; @tracked canActOnDiscoursePostEvent; @tracked shouldDisplayInvitees; @@ -38,6 +40,7 @@ export default class DiscoursePostEventEvent { @tracked recurrence; @tracked recurrenceRule; @tracked customFields; + @tracked channel; @tracked _watchingInvitee; @tracked _sampleInvitees; @@ -63,6 +66,7 @@ export default class DiscoursePostEventEvent { this.isExpired = args.is_expired; this.isStandalone = args.is_standalone; this.minimal = args.minimal; + this.chatEnabled = args.chat_enabled; this.recurrenceRule = args.recurrence_rule; this.recurrence = args.recurrence; this.canUpdateAttendance = args.can_update_attendance; @@ -72,6 +76,9 @@ export default class DiscoursePostEventEvent { this.stats = args.stats; this.reminders = args.reminders; this.customFields = EmberObject.create(args.custom_fields || {}); + if (args.channel) { + this.channel = ChatChannel.create(args.channel); + } } get watchingInvitee() { @@ -138,6 +145,7 @@ export default class DiscoursePostEventEvent { this.isExpired = event.isExpired; this.isStandalone = event.isStandalone; this.minimal = event.minimal; + this.chatEnabled = event.chatEnabled; this.recurrenceRule = event.recurrenceRule; this.recurrence = event.recurrence; this.canUpdateAttendance = event.canUpdateAttendance; diff --git a/assets/javascripts/discourse/pre-initializers/rich-editor-extension.js b/assets/javascripts/discourse/pre-initializers/rich-editor-extension.js index 2aff97d5..a6e63075 100644 --- a/assets/javascripts/discourse/pre-initializers/rich-editor-extension.js +++ b/assets/javascripts/discourse/pre-initializers/rich-editor-extension.js @@ -12,6 +12,8 @@ const EVENT_ATTRIBUTES = { status: { default: "public" }, timezone: { default: "UTC" }, allowedGroups: { default: null }, + chatEnabled: { default: null }, + chatChannelId: { default: null }, }; /** @type {RichEditorExtension} */ diff --git a/assets/stylesheets/common/discourse-post-event.scss b/assets/stylesheets/common/discourse-post-event.scss index 1c6ba723..b0d2a4b1 100644 --- a/assets/stylesheets/common/discourse-post-event.scss +++ b/assets/stylesheets/common/discourse-post-event.scss @@ -350,6 +350,7 @@ $show-interested: inherit; .event-url, .event-dates, + .event-chat-channel, .event-invitees-avatars-container { display: grid; grid-template-columns: 3em 1fr; @@ -357,7 +358,7 @@ $show-interested: inherit; align-items: center; height: min-content; - .d-icon { + > .d-icon { color: var(--primary-high); margin: 0 auto; padding: 0; @@ -398,12 +399,13 @@ $show-interested: inherit; .event-invitees-icon { position: relative; display: flex; + margin: 0 auto; } .event-invitees-icon .going { font-size: var(--font-down-3); position: absolute; - right: 4px; + right: -2px; bottom: -8px; background: var(--secondary); border-radius: 50%; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 5de542ba..33dbbbf7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -460,6 +460,9 @@ en: minimal: label: "Minimal event" checkbox_label: "Hide Going/Not going buttons and invitees status" + allow_chat: + label: "Chat integration" + checkbox_label: "Create and manage event specific chat channel" url: label: "URL" placeholder: "Optional" diff --git a/db/migrate/20250520042223_add_chat_fields_to_events.rb b/db/migrate/20250520042223_add_chat_fields_to_events.rb new file mode 100644 index 00000000..3d479864 --- /dev/null +++ b/db/migrate/20250520042223_add_chat_fields_to_events.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddChatFieldsToEvents < ActiveRecord::Migration[7.2] + def change + add_column :discourse_post_event_events, :chat_enabled, :boolean, default: false, null: false + add_column :discourse_post_event_events, :chat_channel_id, :bigint + end +end diff --git a/lib/discourse_post_event/event_parser.rb b/lib/discourse_post_event/event_parser.rb index 85c1a66d..fd40cde8 100644 --- a/lib/discourse_post_event/event_parser.rb +++ b/lib/discourse_post_event/event_parser.rb @@ -14,6 +14,7 @@ module DiscoursePostEvent :timezone, :minimal, :closed, + :"chat-enabled", ] def self.extract_events(post) diff --git a/spec/services/discourse_post_event/chat_channel_sync_spec.rb b/spec/services/discourse_post_event/chat_channel_sync_spec.rb new file mode 100644 index 00000000..5af48f70 --- /dev/null +++ b/spec/services/discourse_post_event/chat_channel_sync_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +return if !defined?(Chat) + +describe DiscoursePostEvent::ChatChannelSync do + fab!(:user) + fab!(:admin) + fab!(:admin_post) { Fabricate(:post, user: admin) } + + it "is able to create a chat channel and sync members" do + event = Fabricate(:event, chat_enabled: true, post: admin_post) + + expect(event.chat_channel_id).to be_present + expect(event.chat_channel.name).to eq(event.name) + expect(event.chat_channel.user_chat_channel_memberships.count).to eq(1) + expect(event.chat_channel.user_chat_channel_memberships.first.user_id).to eq(admin.id) + + event.create_invitees([user_id: user.id, status: DiscoursePostEvent::Invitee.statuses[:going]]) + event.save! + + expect(event.chat_channel.user_chat_channel_memberships.count).to eq(2) + end + + it "will simply do nothing if user has no permission to create channel" do + post = Fabricate(:post, user: user) + event = Fabricate(:event, chat_enabled: true, post: post) + + expect(event.chat_channel_id).to be_nil + end +end diff --git a/spec/system/post_event_spec.rb b/spec/system/post_event_spec.rb index 8d5a2ac6..eccc7579 100644 --- a/spec/system/post_event_spec.rb +++ b/spec/system/post_event_spec.rb @@ -105,22 +105,25 @@ describe "Post event", type: :system do it "persists changes" do visit "/new-topic" composer.fill_title("Test event with updates") - page.find(".toolbar-popup-menu-options .dropdown-select-box-header").click - page.find( + find(".toolbar-popup-menu-options .dropdown-select-box-header").click + find( ".toolbar-popup-menu-options [data-name='#{I18n.t("js.discourse_post_event.builder_modal.attach")}']", ).click - page.find(".d-modal input[name=status][value=private]").click - page.find(".d-modal input.group-selector").send_keys("test_") - page.find(".autocomplete.ac-group").click - page.find(".d-modal .custom-field-input").fill_in(with: "custom value") - page.find(".d-modal .btn-primary").click - composer.submit - page.find(".discourse-post-event-more-menu-trigger").click - page.find(".edit-event").click + find(".d-modal input[name=status][value=private]").click + find(".d-modal input.group-selector").send_keys("test_") + find(".autocomplete.ac-group").click + find(".d-modal .custom-field-input").fill_in(with: "custom value") + find(".d-modal .btn-primary").click - expect(page.find(".d-modal input[name=status][value=private]").checked?).to eq(true) - expect(page.find(".d-modal")).to have_text("test_group") - expect(page.find(".d-modal .custom-field-input").value).to eq("custom value") + expect(page).to have_no_css(".d-modal") + + composer.submit + find(".discourse-post-event-more-menu-trigger").click + find(".edit-event").click + + expect(find(".d-modal input[name=status][value=private]").checked?).to eq(true) + expect(find(".d-modal")).to have_text("test_group") + expect(find(".d-modal .custom-field-input").value).to eq("custom value") end context "when using bulk inline invite" do