0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/plugins/discourse-calendar/lib/discourse_post_event/event_finder.rb
Kris 2f9f4e8401
FEATURE: render links to event topics as interactive cards (#40953)
When a link to an event topic is oneboxed from another post, it will now
render the event's interactive card instead of a generic quote, which
would sometimes be empty if the only content of the topic was the event.
This covers replies, the composer markdown preview, and the composer
rich text editor. When a card can't render it falls back to the standard
onebox quote.

This mirrors core's `localized_oneboxes` pattern for the event data, and
uses a cooked decorator to swap the quote for `<DiscoursePostEvent>`. In
the rich text editor a ProseMirror NodeView renders the read-only card.

Before:
<img width="828" height="298" alt="image"
src="https://github.com/user-attachments/assets/41df1ece-ca38-4747-8c5a-3dda0b06998a"
/>

After:
<img width="757" height="540" alt="image"
src="https://github.com/user-attachments/assets/6312242d-f7c1-43e3-b641-0d471680c107"
/>

A pared down read-only card is rendered in the composer for preview.

<img width="600" alt="image"
src="https://github.com/user-attachments/assets/c06cd020-6acb-4a45-87be-4621ff2bf308"
/>


<img width="800" alt="image"
src="https://github.com/user-attachments/assets/8250458b-0082-4be8-980a-bfb69a5191d3"
/>
2026-07-17 10:36:54 -04:00

203 lines
6.8 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscoursePostEvent
class EventFinder
def self.search(user, params = {})
guardian = Guardian.new(user)
build_base_query(guardian, user, params)
.then { |query| filter_by_post_id(query, params) }
.then { |query| filter_by_topic_id(query, params) }
.then { |query| filter_by_attending_user(query, params, guardian, user) }
.then { |query| filter_by_dates(query, params) }
.then { |query| filter_by_category(query, params) }
.then { |query| apply_ordering(query, params) }
.then { |query| apply_limit(query, params) }
end
private
def self.build_base_query(guardian, user, params)
topics = listable_topics(guardian)
pms = private_messages(user)
scope = DiscoursePostEvent::Event.visible
scope = scope.open if params[:include_closed].blank?
scope
.joins(post: :topic)
.merge(Post.secured(guardian))
.merge(topics.or(pms))
.joins(latest_event_date_join)
.select(
"discourse_post_event_events.*, latest_event_dates.starts_at, latest_event_dates.ends_at, latest_event_dates.finished_at",
)
.where(
"(discourse_post_event_events.recurrence IS NOT NULL) OR (latest_event_dates.starts_at IS NOT NULL) OR (discourse_post_event_events.original_starts_at IS NOT NULL)",
)
.group(
"discourse_post_event_events.id, latest_event_dates.starts_at, latest_event_dates.ends_at, latest_event_dates.finished_at",
)
end
def self.latest_event_date_join
<<~SQL
LEFT JOIN (
SELECT DISTINCT ON (event_id)
event_id,
starts_at,
ends_at,
finished_at
FROM discourse_calendar_post_event_dates
ORDER BY event_id, #{DiscoursePostEvent::EventDate.current_ordering_sql}
) latest_event_dates ON latest_event_dates.event_id = discourse_post_event_events.id
SQL
end
def self.filter_by_post_id(events, params)
return events if params[:post_id].blank?
events.where(id: params[:post_id])
end
def self.filter_by_topic_id(events, params)
return events if params[:topic_id].blank?
events.where(topics: { id: params[:topic_id] })
end
def self.filter_by_attending_user(events, params, guardian, user)
return events if params[:attending_user].blank?
attending_user = User.find_by(username_lower: params[:attending_user].downcase)
return events.none if !attending_user
statuses = [DiscoursePostEvent::Invitee.statuses[:going]]
if params[:include_interested].present? &&
can_include_interested?(guardian, user, attending_user)
statuses << DiscoursePostEvent::Invitee.statuses[:interested]
end
events =
events.joins(:invitees).where(
discourse_post_event_invitees: {
user_id: attending_user.id,
status: statuses,
},
)
guardian.is_admin? ? events : apply_privacy_restrictions(events, user)
end
def self.can_include_interested?(guardian, user, attending_user)
guardian.is_admin? || user&.id == attending_user.id
end
def self.apply_privacy_restrictions(events, user)
private_status = DiscoursePostEvent::Event.statuses[:private]
# If no user, can only see non-private events
return events.where.not(status: private_status) if user.nil?
# User can see private events if they are invited to them
events.where(
"discourse_post_event_events.status != ? OR (discourse_post_event_events.status = ? AND EXISTS (SELECT 1 FROM discourse_post_event_invitees WHERE post_id = discourse_post_event_events.id AND user_id = ?))",
private_status,
private_status,
user.id,
)
end
def self.filter_by_dates(events, params)
return events if params[:before].blank? && params[:after].blank?
before_date = params[:before] == "now" ? Time.current : params[:before]&.to_datetime
after_date = params[:after] == "now" ? Time.current : params[:after]&.to_datetime
include_ongoing = params[:include_ongoing].present?
recurring_scope = build_recurring_date_scope(after_date, before_date)
non_recurring_scope =
build_non_recurring_date_scope(after_date, before_date, include_ongoing:)
# Apply the combined scope using OR logic
events.merge(recurring_scope.or(non_recurring_scope))
end
private
def self.build_recurring_date_scope(after_date, before_date)
scope = DiscoursePostEvent::Event.where.not(recurrence: nil)
if after_date
# For recurring events: original start date OR recurrence_until should be >= after_date
scope =
scope.where(
"original_starts_at >= ? OR recurrence_until IS NULL OR recurrence_until >= ?",
after_date,
after_date,
)
end
if before_date
# For recurring events: original start date should be < before_date
scope = scope.where("original_starts_at < ?", before_date)
end
scope
end
def self.build_non_recurring_date_scope(after_date, before_date, include_ongoing: false)
scope = DiscoursePostEvent::Event.where(recurrence: nil)
if after_date
if include_ongoing
scope =
scope.where(
"latest_event_dates.starts_at >= ? OR (latest_event_dates.ends_at IS NOT NULL AND latest_event_dates.ends_at >= ?)",
after_date,
after_date,
)
else
scope = scope.where("latest_event_dates.starts_at >= ?", after_date)
end
end
scope = scope.where("latest_event_dates.starts_at < ?", before_date) if before_date
scope
end
def self.filter_by_category(events, params)
return events if params[:category_id].blank?
category_id = params[:category_id].to_i
category_ids =
(
if params[:include_subcategories].present?
Category.subcategory_ids(category_id)
else
[category_id]
end
)
events.where(topics: { category_id: category_ids })
end
def self.apply_ordering(events, params)
order_direction = params[:order] == "desc" ? "DESC" : "ASC"
events.order(
"latest_event_dates.starts_at #{order_direction}, discourse_post_event_events.id #{order_direction}",
)
end
def self.apply_limit(events, params)
limit = params[:limit]&.to_i || 200
events.limit(limit.clamp(1, 200))
end
def self.listable_topics(guardian)
topics = Topic.listable_topics.secured(guardian)
topics = topics.visible unless guardian.can_see_unlisted_topics?
topics
end
def self.private_messages(user)
user ? Topic.private_messages_for_user(user) : Topic.none
end
end
end