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 =
+ {{#if @event.channel}}
+
+ {{/if}}
+;
+
+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