FEATURE: optional attached chat channel for event (#728)

This defines a feature where event creators can opt for an associated chat channel
creation.

This is a staff only feature for now.

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Sam 2025-05-28 16:13:33 +10:00 committed by GitHub
parent 5f1cf802dd
commit 8152a0ca7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 253 additions and 46 deletions

View File

@ -3,18 +3,39 @@
module DiscoursePostEvent module DiscoursePostEvent
class Event < ActiveRecord::Base class Event < ActiveRecord::Base
PUBLIC_GROUP = "trust_level_0" PUBLIC_GROUP = "trust_level_0"
MIN_NAME_LENGTH = 5
MAX_NAME_LENGTH = 255
self.table_name = "discourse_post_event_events" self.table_name = "discourse_post_event_events"
self.ignored_columns = %w[starts_at ends_at] self.ignored_columns = %w[starts_at ends_at]
has_many :event_dates, dependent: :destroy 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 def self.attributes_protected_by_default
super - %w[id] super - %w[id]
end end
after_commit :destroy_topic_custom_field, on: %i[destroy]
def destroy_topic_custom_field def destroy_topic_custom_field
if self.post && self.post.is_first_post? if self.post && self.post.is_first_post?
TopicCustomField.where( TopicCustomField.where(
@ -29,7 +50,6 @@ module DiscoursePostEvent
end end
end end
after_commit :create_or_update_event_date, on: %i[create update]
def create_or_update_event_date def create_or_update_event_date
starts_at_changed = saved_change_to_original_starts_at starts_at_changed = saved_change_to_original_starts_at
ends_at_changed = saved_change_to_original_ends_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 } ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input }
end 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? def expired?
(ends_at || starts_at.end_of_day) <= Time.now (ends_at || starts_at.end_of_day) <= Time.now
end end
@ -103,8 +118,6 @@ module DiscoursePostEvent
event_dates.order(:updated_at, :id).last&.ends_at event_dates.order(:updated_at, :id).last&.ends_at
end end
validates :original_starts_at, presence: true
def on_going_event_invitees def on_going_event_invitees
return [] if !self.ends_at && self.starts_at < Time.now return [] if !self.ends_at && self.starts_at < Time.now
@ -117,15 +130,6 @@ module DiscoursePostEvent
invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going]) invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going])
end 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 def raw_invitees_length
if self.raw_invitees && self.raw_invitees.length > 10 if self.raw_invitees && self.raw_invitees.length > 10
errors.add( errors.add(
@ -135,7 +139,6 @@ module DiscoursePostEvent
end end
end end
validate :raw_invitees_are_groups
def 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 if self.raw_invitees && User.select(:id).where(username: self.raw_invitees).limit(1).count > 0
errors.add( errors.add(
@ -145,7 +148,6 @@ module DiscoursePostEvent
end end
end end
validate :ends_before_start
def ends_before_start def ends_before_start
if self.original_starts_at && self.original_ends_at && if self.original_starts_at && self.original_ends_at &&
self.original_starts_at >= self.original_ends_at self.original_starts_at >= self.original_ends_at
@ -156,7 +158,6 @@ module DiscoursePostEvent
end end
end end
validate :allowed_custom_fields
def allowed_custom_fields def allowed_custom_fields
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|") allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|")
self.custom_fields.each do |key, value| self.custom_fields.each do |key, value|
@ -174,8 +175,12 @@ module DiscoursePostEvent
attrs.map! do |attr| attrs.map! do |attr|
{ post_id: self.id, created_at: timestamp, updated_at: timestamp }.merge(attr) { post_id: self.id, created_at: timestamp, updated_at: timestamp }.merge(attr)
end 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 end
def notify_invitees!(predefined_attendance: false) def notify_invitees!(predefined_attendance: false)
@ -309,6 +314,7 @@ module DiscoursePostEvent
raw_invitees: event_params[:"allowed-groups"]&.split(","), raw_invitees: event_params[:"allowed-groups"]&.split(","),
minimal: event_params[:minimal], minimal: event_params[:minimal],
closed: event_params[:closed] || false, closed: event_params[:closed] || false,
chat_enabled: event_params[:"chat-enabled"]&.downcase == "true",
} }
params[:custom_fields] = {} params[:custom_fields] = {}
@ -364,6 +370,15 @@ module DiscoursePostEvent
self.publish_update! self.publish_update!
end 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) def calculate_next_date(start_date: nil)
localized_start = start_date || original_starts_at.in_time_zone(timezone) localized_start = start_date || original_starts_at.in_time_zone(timezone)
@ -408,4 +423,6 @@ end
# timezone :string # timezone :string
# minimal :boolean # minimal :boolean
# closed :boolean default(FALSE), not null # closed :boolean default(FALSE), not null
# chat_enabled :boolean default(FALSE), not null
# chat_channel_id :bigint
# #

View File

@ -10,9 +10,10 @@ module DiscoursePostEvent
belongs_to :user belongs_to :user
default_scope { joins(:user).includes(:user).where("users.id IS NOT NULL") } default_scope { joins(:user).includes(:user).where("users.id IS NOT NULL") }
scope :with_status, ->(status) { where(status: Invitee.statuses[status]) } scope :with_status, ->(status) { where(status: Invitee.statuses[status]) }
after_commit :sync_chat_channel_members
def self.statuses def self.statuses
@statuses ||= Enum.new(going: 0, interested: 1, not_going: 2) @statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
end end
@ -45,6 +46,11 @@ module DiscoursePostEvent
) )
end end
def sync_chat_channel_members
return if !self.event.chat_enabled?
ChatChannelSync.sync(self.event)
end
def update_topic_tracking! def update_topic_tracking!
topic_id = self.event.post.topic.id topic_id = self.event.post.topic.id
user_id = self.user.id user_id = self.user.id

View File

@ -30,6 +30,16 @@ module DiscoursePostEvent
attributes :timezone attributes :timezone
attributes :url attributes :url
attributes :watching_invitee 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 def can_act_on_discourse_post_event
scope.can_act_on_discourse_post_event?(object) scope.can_act_on_discourse_post_event?(object)

View File

@ -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

View File

@ -0,0 +1,15 @@
import { LinkTo } from "@ember/routing";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
const DiscoursePostEventChatChannel = <template>
{{#if @event.channel}}
<section class="event__section event-chat-channel">
<span></span>
<LinkTo @route="chat.channel" @models={{@event.channel.routeModels}}>
<ChannelTitle @channel={{@event.channel}} />
</LinkTo>
</section>
{{/if}}
</template>;
export default DiscoursePostEventChatChannel;

View File

@ -7,6 +7,7 @@ import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon"; import icon from "discourse/helpers/d-icon";
import replaceEmoji from "discourse/helpers/replace-emoji"; import replaceEmoji from "discourse/helpers/replace-emoji";
import routeAction from "discourse/helpers/route-action"; import routeAction from "discourse/helpers/route-action";
import ChatChannel from "./chat-channel";
import Creator from "./creator"; import Creator from "./creator";
import Dates from "./dates"; import Dates from "./dates";
import EventStatus from "./event-status"; import EventStatus from "./event-status";
@ -124,10 +125,12 @@ export default class DiscoursePostEvent extends Component {
Dates=(component Dates event=@event) Dates=(component Dates event=@event)
Invitees=(component Invitees event=@event) Invitees=(component Invitees event=@event)
Status=(component Status event=@event) Status=(component Status event=@event)
ChatChannel=(component ChatChannel event=@event)
}} }}
> >
<Url @url={{@event.url}} /> <Url @url={{@event.url}} />
<Dates @event={{@event}} /> <Dates @event={{@event}} />
<ChatChannel @event={{@event}} />
<Invitees @event={{@event}} /> <Invitees @event={{@event}} />
{{#if @event.canUpdateAttendance}} {{#if @event.canUpdateAttendance}}
<Status @event={{@event}} /> <Status @event={{@event}} />

View File

@ -17,12 +17,14 @@
@outletArgs={{hash event=@model.event}} @outletArgs={{hash event=@model.event}}
@connectorTagName="div" @connectorTagName="div"
> >
<DateTimeInputRange <EventField>
@from={{this.startsAt}} <DateTimeInputRange
@to={{this.endsAt}} @from={{this.startsAt}}
@timezone={{@model.event.timezone}} @to={{this.endsAt}}
@onChange={{this.onChangeDates}} @timezone={{@model.event.timezone}}
/> @onChange={{this.onChangeDates}}
/>
</EventField>
<EventField <EventField
class="name" class="name"
@ -223,6 +225,22 @@
</label> </label>
</EventField> </EventField>
{{#if this.showChat}}
<EventField
class="allow-chat"
@label="discourse_post_event.builder_modal.allow_chat.label"
>
<label class="checkbox-label">
<Input @type="checkbox" @checked={{@model.event.chatEnabled}} />
<span class="message">
{{i18n
"discourse_post_event.builder_modal.allow_chat.checkbox_label"
}}
</span>
</label>
</EventField>
{{/if}}
{{#if this.allowedCustomFields.length}} {{#if this.allowedCustomFields.length}}
<EventField <EventField
@label="discourse_post_event.builder_modal.custom_fields.label" @label="discourse_post_event.builder_modal.custom_fields.label"

View File

@ -12,6 +12,7 @@ export default class PostEventBuilder extends Component {
@service dialog; @service dialog;
@service siteSettings; @service siteSettings;
@service store; @service store;
@service currentUser;
@tracked flash = null; @tracked flash = null;
@tracked isSaving = false; @tracked isSaving = false;
@ -131,6 +132,14 @@ export default class PostEventBuilder extends Component {
return this.event.reminders?.length >= 5; return this.event.reminders?.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 @action
groupFinder(term) { groupFinder(term) {
return Group.findAll({ term, ignore_automatic: true }); return Group.findAll({ term, ignore_automatic: true });

View File

@ -33,6 +33,10 @@ export function buildParams(startsAt, endsAt, event, siteSettings) {
params.minimal = "true"; params.minimal = "true";
} }
if (event.chatEnabled) {
params.chatEnabled = "true";
}
if (endsAt) { if (endsAt) {
params.end = moment(endsAt).tz(eventTz).format("YYYY-MM-DD HH:mm"); params.end = moment(endsAt).tz(eventTz).format("YYYY-MM-DD HH:mm");
} }

View File

@ -3,6 +3,7 @@ import EmberObject from "@ember/object";
import { TrackedArray } from "@ember-compat/tracked-built-ins"; import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { bind } from "discourse/lib/decorators"; import { bind } from "discourse/lib/decorators";
import User from "discourse/models/user"; 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 DiscoursePostEventEventStats from "./discourse-post-event-event-stats";
import DiscoursePostEventInvitee from "./discourse-post-event-invitee"; import DiscoursePostEventInvitee from "./discourse-post-event-invitee";
@ -29,6 +30,7 @@ export default class DiscoursePostEventEvent {
@tracked status; @tracked status;
@tracked post; @tracked post;
@tracked minimal; @tracked minimal;
@tracked chatEnabled;
@tracked canUpdateAttendance; @tracked canUpdateAttendance;
@tracked canActOnDiscoursePostEvent; @tracked canActOnDiscoursePostEvent;
@tracked shouldDisplayInvitees; @tracked shouldDisplayInvitees;
@ -38,6 +40,7 @@ export default class DiscoursePostEventEvent {
@tracked recurrence; @tracked recurrence;
@tracked recurrenceRule; @tracked recurrenceRule;
@tracked customFields; @tracked customFields;
@tracked channel;
@tracked _watchingInvitee; @tracked _watchingInvitee;
@tracked _sampleInvitees; @tracked _sampleInvitees;
@ -63,6 +66,7 @@ export default class DiscoursePostEventEvent {
this.isExpired = args.is_expired; this.isExpired = args.is_expired;
this.isStandalone = args.is_standalone; this.isStandalone = args.is_standalone;
this.minimal = args.minimal; this.minimal = args.minimal;
this.chatEnabled = args.chat_enabled;
this.recurrenceRule = args.recurrence_rule; this.recurrenceRule = args.recurrence_rule;
this.recurrence = args.recurrence; this.recurrence = args.recurrence;
this.canUpdateAttendance = args.can_update_attendance; this.canUpdateAttendance = args.can_update_attendance;
@ -72,6 +76,9 @@ export default class DiscoursePostEventEvent {
this.stats = args.stats; this.stats = args.stats;
this.reminders = args.reminders; this.reminders = args.reminders;
this.customFields = EmberObject.create(args.custom_fields || {}); this.customFields = EmberObject.create(args.custom_fields || {});
if (args.channel) {
this.channel = ChatChannel.create(args.channel);
}
} }
get watchingInvitee() { get watchingInvitee() {
@ -138,6 +145,7 @@ export default class DiscoursePostEventEvent {
this.isExpired = event.isExpired; this.isExpired = event.isExpired;
this.isStandalone = event.isStandalone; this.isStandalone = event.isStandalone;
this.minimal = event.minimal; this.minimal = event.minimal;
this.chatEnabled = event.chatEnabled;
this.recurrenceRule = event.recurrenceRule; this.recurrenceRule = event.recurrenceRule;
this.recurrence = event.recurrence; this.recurrence = event.recurrence;
this.canUpdateAttendance = event.canUpdateAttendance; this.canUpdateAttendance = event.canUpdateAttendance;

View File

@ -12,6 +12,8 @@ const EVENT_ATTRIBUTES = {
status: { default: "public" }, status: { default: "public" },
timezone: { default: "UTC" }, timezone: { default: "UTC" },
allowedGroups: { default: null }, allowedGroups: { default: null },
chatEnabled: { default: null },
chatChannelId: { default: null },
}; };
/** @type {RichEditorExtension} */ /** @type {RichEditorExtension} */

View File

@ -350,6 +350,7 @@ $show-interested: inherit;
.event-url, .event-url,
.event-dates, .event-dates,
.event-chat-channel,
.event-invitees-avatars-container { .event-invitees-avatars-container {
display: grid; display: grid;
grid-template-columns: 3em 1fr; grid-template-columns: 3em 1fr;
@ -357,7 +358,7 @@ $show-interested: inherit;
align-items: center; align-items: center;
height: min-content; height: min-content;
.d-icon { > .d-icon {
color: var(--primary-high); color: var(--primary-high);
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
@ -398,12 +399,13 @@ $show-interested: inherit;
.event-invitees-icon { .event-invitees-icon {
position: relative; position: relative;
display: flex; display: flex;
margin: 0 auto;
} }
.event-invitees-icon .going { .event-invitees-icon .going {
font-size: var(--font-down-3); font-size: var(--font-down-3);
position: absolute; position: absolute;
right: 4px; right: -2px;
bottom: -8px; bottom: -8px;
background: var(--secondary); background: var(--secondary);
border-radius: 50%; border-radius: 50%;

View File

@ -460,6 +460,9 @@ en:
minimal: minimal:
label: "Minimal event" label: "Minimal event"
checkbox_label: "Hide Going/Not going buttons and invitees status" 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: url:
label: "URL" label: "URL"
placeholder: "Optional" placeholder: "Optional"

View File

@ -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

View File

@ -14,6 +14,7 @@ module DiscoursePostEvent
:timezone, :timezone,
:minimal, :minimal,
:closed, :closed,
:"chat-enabled",
] ]
def self.extract_events(post) def self.extract_events(post)

View File

@ -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

View File

@ -105,22 +105,25 @@ describe "Post event", type: :system do
it "persists changes" do it "persists changes" do
visit "/new-topic" visit "/new-topic"
composer.fill_title("Test event with updates") composer.fill_title("Test event with updates")
page.find(".toolbar-popup-menu-options .dropdown-select-box-header").click find(".toolbar-popup-menu-options .dropdown-select-box-header").click
page.find( find(
".toolbar-popup-menu-options [data-name='#{I18n.t("js.discourse_post_event.builder_modal.attach")}']", ".toolbar-popup-menu-options [data-name='#{I18n.t("js.discourse_post_event.builder_modal.attach")}']",
).click ).click
page.find(".d-modal input[name=status][value=private]").click find(".d-modal input[name=status][value=private]").click
page.find(".d-modal input.group-selector").send_keys("test_") find(".d-modal input.group-selector").send_keys("test_")
page.find(".autocomplete.ac-group").click find(".autocomplete.ac-group").click
page.find(".d-modal .custom-field-input").fill_in(with: "custom value") find(".d-modal .custom-field-input").fill_in(with: "custom value")
page.find(".d-modal .btn-primary").click find(".d-modal .btn-primary").click
composer.submit
page.find(".discourse-post-event-more-menu-trigger").click
page.find(".edit-event").click
expect(page.find(".d-modal input[name=status][value=private]").checked?).to eq(true) expect(page).to have_no_css(".d-modal")
expect(page.find(".d-modal")).to have_text("test_group")
expect(page.find(".d-modal .custom-field-input").value).to eq("custom value") 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 end
context "when using bulk inline invite" do context "when using bulk inline invite" do