From 466ad85c99e9d773831076876554b7ff070b41c2 Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Thu, 9 Apr 2026 17:08:52 -0400 Subject: [PATCH 1/2] Add dedicated event model (#23115) * Add dedicated event model * Adjust data migrations * Clean things up * Add billboard config * Update events and billboards * Clean up validations and API --- app/assets/stylesheets/views.scss | 1 + app/assets/stylesheets/views/events.scss | 226 +++++++++++++++++ app/controllers/admin/events_controller.rb | 61 +++++ app/controllers/api/v0/events_controller.rb | 90 +++++++ app/controllers/events_controller.rb | 23 ++ app/models/admin_menu.rb | 1 + app/models/billboard.rb | 1 + app/models/event.rb | 236 ++++++++++++++++++ app/policies/event_policy.rb | 31 +++ app/queries/billboards/filtered_ads_query.rb | 36 +++ app/views/admin/events/_form.html.erb | 86 +++++++ app/views/admin/events/edit.html.erb | 9 + app/views/admin/events/index.html.erb | 43 ++++ app/views/admin/events/new.html.erb | 4 + app/views/events/index.html.erb | 33 +++ app/views/events/show.html.erb | 91 +++++++ .../manage_broadcast_billboards_worker.rb | 42 ++++ config/routes.rb | 3 + config/routes/admin.rb | 1 + config/routes/api.rb | 1 + config/schedule.yml | 4 + .../20260409163500_initialize_events_table.rb | 30 +++ ...60409170801_add_event_id_to_display_ads.rb | 15 ++ ...lidate_event_foreign_key_on_display_ads.rb | 6 + db/schema.rb | 32 ++- db/seeds.rb | 35 +++ spec/factories/events.rb | 21 ++ spec/models/event_spec.rb | 127 ++++++++++ .../billboards/filtered_ads_query_spec.rb | 45 ++++ spec/requests/admin/events_spec.rb | 53 ++++ spec/requests/api/v0/events_spec.rb | 103 ++++++++ spec/requests/api/v1/billboards_spec.rb | 6 +- spec/requests/events_spec.rb | 44 ++++ ...manage_broadcast_billboards_worker_spec.rb | 68 +++++ 34 files changed, 1604 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/views/events.scss create mode 100644 app/controllers/admin/events_controller.rb create mode 100644 app/controllers/api/v0/events_controller.rb create mode 100644 app/controllers/events_controller.rb create mode 100644 app/models/event.rb create mode 100644 app/policies/event_policy.rb create mode 100644 app/views/admin/events/_form.html.erb create mode 100644 app/views/admin/events/edit.html.erb create mode 100644 app/views/admin/events/index.html.erb create mode 100644 app/views/admin/events/new.html.erb create mode 100644 app/views/events/index.html.erb create mode 100644 app/views/events/show.html.erb create mode 100644 app/workers/events/manage_broadcast_billboards_worker.rb create mode 100644 db/migrate/20260409163500_initialize_events_table.rb create mode 100644 db/migrate/20260409170801_add_event_id_to_display_ads.rb create mode 100644 db/migrate/20260409173612_validate_event_foreign_key_on_display_ads.rb create mode 100644 spec/factories/events.rb create mode 100644 spec/models/event_spec.rb create mode 100644 spec/requests/admin/events_spec.rb create mode 100644 spec/requests/api/v0/events_spec.rb create mode 100644 spec/requests/events_spec.rb create mode 100644 spec/workers/events/manage_broadcast_billboards_worker_spec.rb diff --git a/app/assets/stylesheets/views.scss b/app/assets/stylesheets/views.scss index 7e77c66ba49ad..9a7590052c367 100644 --- a/app/assets/stylesheets/views.scss +++ b/app/assets/stylesheets/views.scss @@ -10,3 +10,4 @@ @import 'views/mod-center'; @import 'views/signin'; @import 'views/signup-modal'; +@import 'views/events'; diff --git a/app/assets/stylesheets/views/events.scss b/app/assets/stylesheets/views/events.scss new file mode 100644 index 0000000000000..5d8904f3590b7 --- /dev/null +++ b/app/assets/stylesheets/views/events.scss @@ -0,0 +1,226 @@ +.event-show-layout { + padding-top: var(--su-4); + + .event-header { + display: flex; + align-items: center; + gap: var(--su-4); + margin-bottom: var(--su-4); + + h1 { + font-size: var(--fs-3xl); + font-weight: 800; + color: var(--base-100); + line-height: var(--lh-tight); + margin: 0; + } + + .live-indicator { + background-color: var(--error); + color: white; + padding: var(--su-1) var(--su-2); + border-radius: var(--radius); + font-weight: bold; + font-size: var(--fs-s); + letter-spacing: 0.5px; + animation: pulse 2s infinite; + } + } + + .stream-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--su-4); + background: var(--base-0); + border-radius: var(--radius); + padding: var(--su-4); + box-shadow: var(--shadow-sm); + + @media screen and (min-width: 992px) { + grid-template-columns: 3fr 1fr; + } + + .video-wrapper { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + overflow: hidden; + border-radius: var(--radius); + background: var(--base-10); + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--radius); + } + } + + .chat-wrapper { + height: 400px; + border-radius: var(--radius); + overflow: hidden; + background: var(--base-10); + + @media screen and (min-width: 992px) { + height: 100%; + } + + iframe { + width: 100%; + height: 100%; + border-radius: var(--radius); + } + } + } + + .info-section { + display: flex; + flex-direction: column; + gap: var(--su-4); + + p { + color: var(--base-70); + font-size: var(--fs-l); + line-height: var(--lh-relaxed); + } + + .profile-card { + display: inline-flex; + align-items: center; + gap: var(--su-3); + padding: var(--su-3); + border: 1px solid var(--base-20); + border-radius: var(--radius); + text-decoration: none; + color: var(--base-90); + background: var(--base-0); + transition: all 0.2s ease; + align-self: flex-start; + + &:hover { + background: var(--base-5); + border-color: var(--base-40); + box-shadow: var(--shadow-sm); + } + + img { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + } + + div { + display: flex; + flex-direction: column; + + strong { + color: var(--base-100); + } + + span { + color: var(--base-60); + font-size: var(--fs-s); + } + } + } + } + + .community-header { + h2 { + font-size: var(--fs-2xl); + } + } + + .feed-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--su-4); + + @media screen and (min-width: 768px) { + grid-template-columns: repeat(2, 1fr); + } + + @media screen and (min-width: 1024px) { + grid-template-columns: repeat(3, 1fr); + } + + .article-card { + background: var(--base-0); + border-radius: var(--radius-md); + padding: var(--su-4); + box-shadow: 0 0 0 1px var(--base-20); + display: flex; + flex-direction: column; + gap: var(--su-3); + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: 0 0 0 1px var(--base-20), var(--shadow-sm); + } + + .article-user { + display: flex; + align-items: center; + gap: var(--su-2); + + img { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + } + + span { + font-size: var(--fs-s); + color: var(--base-70); + font-weight: 500; + } + } + + a { + text-decoration: none; + color: var(--base-100); + + &:hover { + color: var(--link-color); + } + + .article-title { + font-size: var(--fs-l); + font-weight: 700; + line-height: var(--lh-tight); + margin: 0; + } + } + + .article-tags { + display: flex; + flex-wrap: wrap; + gap: var(--su-2); + + .tag { + font-size: var(--fs-xs); + color: var(--base-60); + background: var(--base-10); + padding: 2px var(--su-2); + border-radius: var(--radius); + } + } + + .article-meta { + margin-top: auto; + font-size: var(--fs-xs); + color: var(--base-60); + padding-top: var(--su-2); + } + } + } +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb new file mode 100644 index 0000000000000..11b90b008e323 --- /dev/null +++ b/app/controllers/admin/events_controller.rb @@ -0,0 +1,61 @@ +module Admin + class EventsController < Admin::ApplicationController + before_action :set_event, only: %i[edit update destroy] + + def index + @events = Event.all.order(created_at: :desc) + end + + def new + @event = Event.new + end + + def create + @event = Event.new(event_params) + if @event.save + redirect_to admin_events_path, notice: "Event created successfully." + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @event.update(event_params) + redirect_to admin_events_path, notice: "Event updated successfully." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @event.destroy + redirect_to admin_events_path, notice: "Event destroyed successfully." + end + + private + + def set_event + @event = Event.find(params[:id]) + end + + def event_params + params.require(:event).permit( + :title, + :event_name_slug, + :event_variation_slug, + :description, + :primary_stream_url, + :published, + :start_time, + :end_time, + :type_of, + :user_id, + :organization_id, + :tag_list, + data: {} + ) + end + end +end diff --git a/app/controllers/api/v0/events_controller.rb b/app/controllers/api/v0/events_controller.rb new file mode 100644 index 0000000000000..9f884a849285b --- /dev/null +++ b/app/controllers/api/v0/events_controller.rb @@ -0,0 +1,90 @@ +module Api + module V0 + class EventsController < ApiController + skip_before_action :verify_authenticity_token, only: %i[create update destroy] + before_action :authenticate!, except: %i[index show] + before_action :set_event, only: %i[show update destroy] + + # Authentication is optional for index and show + # We manually attempt to authenticate to populate current_user if the token is present + before_action :evaluate_authentication, only: %i[index show] + + def index + @events = Event.all + unless @user&.administrative_access_to?(resource: Event) + @events = @events.published + end + render json: @events.order(created_at: :desc) + end + + def show + unless @event.published? || @user&.administrative_access_to?(resource: Event) + return render json: { error: "Event not found" }, status: :not_found + end + render json: @event + end + + def create + authorize Event + @event = Event.new(event_params.except(:user_id)) + @event.user_id = @user.id if @event.user_id.blank? + + if @event.save + render json: @event, status: :created + else + render json: { error: @event.errors.full_messages }, status: :unprocessable_entity + end + end + + def update + authorize @event + # Prevents arbitrary user hijacking via parameters: + if @event.update(event_params.except(:user_id)) + render json: @event + else + render json: { error: @event.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + authorize @event + @event.destroy + head :no_content + end + + private + + def evaluate_authentication + # Forem's ApiController usually requires valid token if provided, but optional if omitted. + # This safely tries to log them in if token is sent. + if request.headers["api-key"] + authenticate! + end + end + + def set_event + @event = Event.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Event not found" }, status: :not_found + end + + def event_params + params.require(:event).permit( + :title, + :event_name_slug, + :event_variation_slug, + :description, + :primary_stream_url, + :published, + :start_time, + :end_time, + :type_of, + :user_id, + :organization_id, + :tag_list, + data: {} + ) + end + end + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 0000000000000..e2e9df4917d57 --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,23 @@ +class EventsController < ApplicationController + def index + @events = Event.published.where('end_time >= ?', Time.current).order(start_time: :asc) + end + + def show + @event = Event.find_by!( + event_name_slug: params[:event_name_slug], + event_variation_slug: params[:event_variation_slug] + ) + + unless @event.published? + raise ActionController::RoutingError.new('Not Found') + end + + tag_name = @event.tags.first&.name + if tag_name.present? + @articles = Article.published.cached_tagged_with(tag_name).order(hotness_score: :desc).limit(15) + else + @articles = Article.published.order(hotness_score: :desc).limit(15) + end + end +end diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb index 41e21808cd367..705aff70a1193 100644 --- a/app/models/admin_menu.rb +++ b/app/models/admin_menu.rb @@ -24,6 +24,7 @@ class AdminMenu item(name: "tags"), item(name: "emails"), item(name: "surveys"), + item(name: "events"), ] scope :customization, "tools-line", [ diff --git a/app/models/billboard.rb b/app/models/billboard.rb index b4415f16743e6..4d09246e2abf9 100644 --- a/app/models/billboard.rb +++ b/app/models/billboard.rb @@ -5,6 +5,7 @@ class Billboard < ApplicationRecord belongs_to :creator, class_name: "User", optional: true belongs_to :audience_segment, optional: true belongs_to :page, optional: true + belongs_to :event, optional: true ALLOWED_PLACEMENT_AREAS = %w[sidebar_left sidebar_left_2 diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000000000..ae4ee72202cad --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,236 @@ +class Event < ApplicationRecord + include Taggable + acts_as_taggable_on :tags + + belongs_to :user, optional: true + belongs_to :organization, optional: true + + has_many :billboards, foreign_key: :event_id, dependent: :destroy + + enum type_of: { live_stream: 0, takeover: 1, other: 2 } + enum broadcast_config: { no_broadcast: 0, tagged_broadcast: 1, global_broadcast: 2 } + + validates :title, presence: true + validates :start_time, presence: true + validates :end_time, presence: true + validates :event_name_slug, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "can only contain lowercase letters, numbers, and dashes" } + validates :event_variation_slug, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "can only contain lowercase letters, numbers, and dashes" }, uniqueness: { scope: :event_name_slug, case_sensitive: false } + validate :end_time_after_start_time + validates :primary_stream_url, format: { with: /\Ahttps:\/\/(www\.)?(youtube\.com|youtu\.be|twitch\.tv|player\.twitch\.tv)\/.*\z/, message: "must be a valid HTTPS YouTube or Twitch URL" }, allow_blank: true + + before_save :format_stream_urls + after_commit :ensure_broadcast_billboards_and_workers, on: [:create, :update] + + scope :published, -> { where(published: true) } + + private + + def end_time_after_start_time + return if end_time.blank? || start_time.blank? + + if end_time < start_time + errors.add(:end_time, "must be after the start time") + end + end + + def format_stream_urls + return if primary_stream_url.blank? + + app_domain = Settings::General.app_domain.split(":")[0] + self.data ||= {} + + youtube_match = primary_stream_url.match(%r{(?:youtu\.be/|youtube\.com/(?:watch\?v=|embed/|live/|v/|shorts/))([a-zA-Z0-9_-]{11})}i) || + primary_stream_url.match(%r{[?&]v=([a-zA-Z0-9_-]{11})}i) || + primary_stream_url.match(/\A([a-zA-Z0-9_-]{11})\z/) + + if youtube_match + video_id = youtube_match[1] + self.primary_stream_url = "https://www.youtube.com/embed/#{video_id}?autoplay=1" + self.data["chat_url"] = "https://www.youtube.com/live_chat?v=#{video_id}&embed_domain=#{app_domain}" + elsif primary_stream_url.match?(%r{twitch\.tv}i) + channel_name = nil + if primary_stream_url.include?("channel=") + match = primary_stream_url.match(/channel=([a-zA-Z0-9_]+)/i) + channel_name = match[1] if match + else + match = primary_stream_url.match(%r{twitch\.tv/([a-zA-Z0-9_]+)}i) + channel_name = match[1] if match + end + + if channel_name && !%w[videos clip clips directory].include?(channel_name.downcase) + self.primary_stream_url = "https://player.twitch.tv/?channel=#{channel_name}&parent=#{app_domain}" + self.data["chat_url"] = "https://www.twitch.tv/embed/#{channel_name}/chat?parent=#{app_domain}" + end + end + end + + def ensure_broadcast_billboards_and_workers + return if no_broadcast? + + # Only process if it has a published state linking (though we generate them regardless) + # Billboard templates + home_feed_bb = billboards.find_or_initialize_by(placement_area: "feed_first") + home_feed_bb.update!( + name: "Event #{id} Broadcast - Home Feed", + body_markdown: generated_takeover_feed_html, + organization_id: organization_id, + creator_id: user_id, + color: "#18181A", + approved: home_feed_bb.new_record? ? false : home_feed_bb.approved, + published: true + ) + + post_bottom_bb = billboards.find_or_initialize_by(placement_area: "post_fixed_bottom") + post_bottom_bb.update!( + name: "Event #{id} Broadcast - Post Bottom", + body_markdown: generated_takeover_post_html, + organization_id: organization_id, + creator_id: user_id, + color: "#18181A", + approved: post_bottom_bb.new_record? ? false : post_bottom_bb.approved, + published: true + ) + end + + def generated_takeover_post_html + image_url = data["image_url"] || organization&.profile_image_url || user&.profile_image_url + link = "/events/#{event_name_slug}/#{event_variation_slug}" + + <<~HTML + + +
+
+ #{title} +
+
+

+ #{title} +

+

+ #{description} +

+

+ + Tune in to the full event + +

+

+ #{Settings::Community.community_name} is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️ +

+
+
+ HTML + end + + def generated_takeover_feed_html + image_url = data["image_url"] || organization&.profile_image_url || user&.profile_image_url + link = "/events/#{event_name_slug}/#{event_variation_slug}" + + <<~HTML + + +

+ #{title} +

+ + #{title} + +

+ #{description} +

+ +

+ + Tune in to the full event + +

+ +

+ #{Settings::Community.community_name} is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️ +

+ HTML + end +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb new file mode 100644 index 0000000000000..5e401326e420d --- /dev/null +++ b/app/policies/event_policy.rb @@ -0,0 +1,31 @@ +class EventPolicy < ApplicationPolicy + def index? + true + end + + def show? + record.published? || user&.administrative_access_to?(resource: Event) + end + + def create? + user&.administrative_access_to?(resource: Event) + end + + def update? + create? + end + + def destroy? + create? + end + + class Scope < Scope + def resolve + if user&.administrative_access_to?(resource: Event) + scope.all + else + scope.published + end + end + end +end diff --git a/app/queries/billboards/filtered_ads_query.rb b/app/queries/billboards/filtered_ads_query.rb index 4de884ae07b92..01f8d52170f88 100644 --- a/app/queries/billboards/filtered_ads_query.rb +++ b/app/queries/billboards/filtered_ads_query.rb @@ -33,6 +33,9 @@ def initialize(area:, user_signed_in:, organization_id: nil, article_tags: [], p def call @filtered_billboards = approved_and_published_ads @filtered_billboards = placement_area_ads + + apply_event_broadcast_overrides! + @filtered_billboards = included_subforem_ads # if @subforem_id.present? @filtered_billboards = browser_context_ads if @user_agent.present? @filtered_billboards = page_ads if @page_id.present? @@ -92,6 +95,39 @@ def call private + def apply_event_broadcast_overrides! + if @area.in?(%w[feed_first post_fixed_bottom]) + active_events = Rails.cache.fetch("active_broadcast_event_for_#{@area}", expires_in: 30.seconds) do + Event.published + .where.not(broadcast_config: "no_broadcast") + .where("start_time <= ? AND end_time >= ?", Time.current + 15.minutes, Time.current - 5.minutes) + .to_a + end + + verified_event_ids = [] + active_events.each do |active_event| + if active_event.global_broadcast? || (active_event.tagged_broadcast? && event_matches_tags?(active_event)) + verified_event_ids << active_event.id + end + end + + if verified_event_ids.any? + @filtered_billboards = @filtered_billboards.where(event_id: verified_event_ids) + return + end + end + + # For untouched configurations OR silent areas, strip any billboards intrinsically attached to Events so they don't organically leak across default displays! + @filtered_billboards = @filtered_billboards.where(event_id: nil) + end + + def event_matches_tags?(event) + event_tags = event.tags_array || [] + return false if event_tags.empty? + + (@article_tags & event_tags).any? || (@user_tags.to_a & event_tags).any? + end + def approved_and_published_ads @filtered_billboards.approved_and_published end diff --git a/app/views/admin/events/_form.html.erb b/app/views/admin/events/_form.html.erb new file mode 100644 index 0000000000000..e38117b79ed18 --- /dev/null +++ b/app/views/admin/events/_form.html.erb @@ -0,0 +1,86 @@ +<%= form_with model: [:admin, event], local: true do |form| %> +
+
+ <%= form.label :title, class: "crayons-field__label" %> + <%= form.text_field :title, class: "crayons-textfield", required: true %> +
+ +
+
+ <%= form.label :event_name_slug, "Event Name Slug", class: "crayons-field__label" do %> + Event Name Slug * +

The root name grouping of the event. Requires lowercase alphanumerical and dashes (e.g., 'aws-industries').

+ <% end %> + <%= form.text_field :event_name_slug, class: "crayons-textfield", required: true, pattern: "[a-z0-9-]+" %> +
+ +
+ <%= form.label :event_variation_slug, "Event Variation Slug", class: "crayons-field__label" do %> + Event Variation Slug * +

The unique date or subtitle identifier for this iteration. Requires lowercase alphanumerical and dashes (e.g., 'march-31-2026').

+ <% end %> + <%= form.text_field :event_variation_slug, class: "crayons-textfield", required: true, pattern: "[a-z0-9-]+" %> +
+
+ +
+ <%= form.label :description, class: "crayons-field__label" %> + <%= form.text_area :description, class: "crayons-textfield", rows: 3 %> +
+ +
+ <%= form.label :type_of, "Event Type", class: "crayons-field__label" %> + <%= form.select :type_of, Event.type_ofs.keys.map { |type| [type.titleize, type] }, {}, class: "crayons-select" %> +
+ +
+ <%= form.label :primary_stream_url, "Primary Stream URL", class: "crayons-field__label" do %> + Primary Stream URL +

The streaming embed URL (e.g., Twitch, YouTube player src)

+ <% end %> + <%= form.text_area :primary_stream_url, class: "crayons-textfield", rows: 2 %> +
+ +
+ <%= form.fields_for :data, event.data || {} do |data_form| %> + <%= data_form.label :chat_url, "Chat Embed URL", class: "crayons-field__label" do %> + Chat Embed URL +

Optional. The chat embed URL (e.g., Twitch Chat src)

+ <% end %> + + <% end %> +
+ +
+ <%= form.label :tag_list, "Tags", class: "crayons-field__label" do %> + Tags +

Comma separated tags. The first tag is used to fetch related articles.

+ <% end %> + <%= form.text_field :tag_list, value: event.tag_list.join(", "), class: "crayons-textfield", placeholder: "aws, ai, streaming" %> +
+ +
+
+ <%= form.label :start_time, class: "crayons-field__label" %> + <%= form.datetime_field :start_time, class: "crayons-textfield", required: true %> +
+ +
+ <%= form.label :end_time, class: "crayons-field__label" %> + <%= form.datetime_field :end_time, class: "crayons-textfield", required: true %> +
+
+ +
+ <%= form.check_box :published, class: "crayons-checkbox" %> + <%= form.label :published, class: "crayons-field__label" do %> + Published +

Determines if the event is publicly accessible.

+ <% end %> +
+ +
+ <%= form.submit class: "c-btn c-btn--primary" %> +
+
+<% end %> diff --git a/app/views/admin/events/edit.html.erb b/app/views/admin/events/edit.html.erb new file mode 100644 index 0000000000000..64e3c5c75c47a --- /dev/null +++ b/app/views/admin/events/edit.html.erb @@ -0,0 +1,9 @@ +
+
+

Edit Event

+ <% if @event.published? %> + <%= link_to "View Event", event_path(@event.event_name_slug, @event.event_variation_slug), target: "_blank", class: "c-btn c-btn--secondary" %> + <% end %> +
+ <%= render "form", event: @event %> +
diff --git a/app/views/admin/events/index.html.erb b/app/views/admin/events/index.html.erb new file mode 100644 index 0000000000000..dd4b837eabf05 --- /dev/null +++ b/app/views/admin/events/index.html.erb @@ -0,0 +1,43 @@ +
+
+

Events

+ <%= link_to "New Event", new_admin_event_path, class: "c-btn c-btn--primary" %> +
+ + + + + + + + + + + + + <% @events.each do |event| %> + + + + + + + + <% end %> + +
TitleStart TimeTypeStatusActions
<%= event.title %><%= event.start_time&.to_fs(:short) %><%= event.type_of.titleize %> + <% if event.published? %> + Published + <% else %> + Draft + <% end %> + +
+ <%= link_to "Edit", edit_admin_event_path(event), class: "c-btn c-btn--secondary btn-sm" %> + <% if event.published? %> + <%= link_to "View", event_path(event.event_name_slug, event.event_variation_slug), target: "_blank", class: "c-btn c-btn--secondary btn-sm" %> + <% end %> + <%= link_to "Delete", admin_event_path(event), method: :delete, data: { confirm: "Are you sure?" }, class: "c-btn c-btn--destructive btn-sm" %> +
+
+
diff --git a/app/views/admin/events/new.html.erb b/app/views/admin/events/new.html.erb new file mode 100644 index 0000000000000..f7a900d8eb86e --- /dev/null +++ b/app/views/admin/events/new.html.erb @@ -0,0 +1,4 @@ +
+

New Event

+ <%= render "form", event: @event %> +
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb new file mode 100644 index 0000000000000..9736154ece2bf --- /dev/null +++ b/app/views/events/index.html.erb @@ -0,0 +1,33 @@ +
+
+

Upcoming Events

+
+ + <% if @events.any? %> +
+ <% @events.each do |event| %> +
+
+
+ <%= event.type_of.to_s.titleize %> + <% if event.start_time <= Time.current && event.end_time >= Time.current %> + LIVE + <% else %> + <%= event.start_time.to_fs(:short) %> + <% end %> +
+

+ <%= link_to event.title, event_path(event.event_name_slug, event.event_variation_slug), class: "crayons-link" %> +

+

<%= event.description %>

+
+ <%= link_to "View Event", event_path(event.event_name_slug, event.event_variation_slug), class: "c-btn w-100 text-center" %> +
+ <% end %> +
+ <% else %> +
+

No upcoming events scheduled right now. Check back soon!

+
+ <% end %> +
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb new file mode 100644 index 0000000000000..153f64c6ac30b --- /dev/null +++ b/app/views/events/show.html.erb @@ -0,0 +1,91 @@ + +
+ +
+

<%= @event.title %>

+ <% if @event.start_time <= Time.current && @event.end_time >= Time.current %> + LIVE + <% end %> +
+ +
+
+ <% if @event.primary_stream_url.present? %> + + <% else %> +
+

Stream URL not configured.

+
+ <% end %> +
+ + <% chat_url = @event.data&.dig('chat_url') || @event.data&.dig(:chat_url) %> + <% if chat_url.present? %> +
+ +
+ <% end %> +
+ +
+
+ <%= simple_format(@event.description) %> +
+ + <% if @event.organization %> + + <%= @event.organization.name %> Logo +
+ <%= @event.organization.name %> + @<%= @event.organization.slug %> +
+
+ <% elsif @event.user %> + + <%= @event.user.name %> Silhouette +
+ <%= @event.user.name %> + @<%= @event.user.username %> +
+
+ <% end %> +
+ + <% tag_name = @event.tags.first&.name %> + + <% if @articles.any? %> +
+

+ <% if tag_name.present? %> + What the <%= tag_name.upcase %> community has been writing about + <% else %> + What the community has been writing about + <% end %> +

+
+ +
+ <% @articles.each do |article| %> +
+
+ <%= article.user.name %> + <%= article.user.name %> +
+ +

<%= article.title %>

+
+ + +
+ <% end %> +
+ <% end %> +
diff --git a/app/workers/events/manage_broadcast_billboards_worker.rb b/app/workers/events/manage_broadcast_billboards_worker.rb new file mode 100644 index 0000000000000..754e65cd61ee3 --- /dev/null +++ b/app/workers/events/manage_broadcast_billboards_worker.rb @@ -0,0 +1,42 @@ +module Events + class ManageBroadcastBillboardsWorker + include Sidekiq::Job + sidekiq_options queue: :high_priority, retry: 3, lock: :until_executing, on_conflict: :replace + + def perform + # Look for all active broadcasts + # An active broadcast event is one where the current time falls + # between start_time - 15 minutes and end_time + 5 minutes allowing wiggle room + active_events = Event.published.where.not(broadcast_config: 0) + .where("start_time <= ? AND end_time >= ?", Time.current + 15.minutes, Time.current - 5.minutes) + .pluck(:id) + + # We must turn ON billboards for active events, and OFF for ALL OTHER EXPIRED event broadcasts. + # To do this safely: + # Step 1: Any billboard owned by an event that IS currently active, should be approved. + newly_approved_count = 0 + if active_events.any? + Billboard.where(event_id: active_events, approved: false).find_each do |bb| + bb.update!(approved: true) + newly_approved_count += 1 + # Manually purge the specific billboard cache when activating since being_taken_down is false + EdgeCache::PurgeByKey.call(bb.record_key) + end + EdgeCache::PurgeByKey.call("main_app_home_page", fallback_paths: "/") if newly_approved_count > 0 + end + + # Step 2: Any billboard owned by a broadcast_config event that is NOT currently active, should be unapproved. + # That essentially means: event_id is not null, AND event_id is not in active_events. + billboards_to_disable = Billboard.where.not(event_id: nil) + billboards_to_disable = billboards_to_disable.where.not(event_id: active_events) if active_events.any? + + newly_disabled_count = 0 + billboards_to_disable.where(approved: true).find_each do |bb| + # This update triggers bust_billboard_cache naturally + bb.update!(approved: false) + newly_disabled_count += 1 + end + EdgeCache::PurgeByKey.call("main_app_home_page", fallback_paths: "/") if newly_disabled_count > 0 + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 420b95ce4f217..53d186c1d9803 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,6 +107,7 @@ scope module: :v0, constraints: ApiConstraints.new(version: 0, default: true) do post "/auth/mobile_exchange", to: "mobile_auth#create" + resources :events, only: %i[index show create update destroy] draw :api end end @@ -130,6 +131,8 @@ patch "/admin_unpublish", to: "articles#admin_unpublish" patch "/admin_featured_toggle", to: "articles#admin_featured_toggle" end + resources :events, only: %i[index] + get "events/:event_name_slug/:event_variation_slug", to: "events#show", as: :event resources :article_mutes, only: %i[update] resources :comments, only: %i[create update destroy] do patch "/hide", to: "comments#hide" diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 265b76f70a776..b24924df57fff 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -136,6 +136,7 @@ resource :moderator, only: %i[create destroy], module: "tags" end resources :surveys + resources :events end scope :customization do diff --git a/config/routes/api.rb b/config/routes/api.rb index 7ab4d78711a28..69c812e0377a9 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -68,6 +68,7 @@ end end + resource :instance, only: %i[show] constraints(RailsEnvConstraint.new(allowed_envs: %w[test])) do diff --git a/config/schedule.yml b/config/schedule.yml index f300ddff6fa7a..083d8c6296be3 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -202,3 +202,7 @@ reverify_organizations: description: "Re-checks verified organizations to ensure linkback still exists (runs daily at 06:00 UTC)" cron: "0 6 * * *" class: "Organizations::ReverifyAllWorker" +events_manage_broadcast_billboards: + description: "Manages automatic Billboard deployments natively toggling configurations depending on Event start/end boundaries (runs every 5 minutes)" + cron: "*/5 * * * *" + class: "Events::ManageBroadcastBillboardsWorker" diff --git a/db/migrate/20260409163500_initialize_events_table.rb b/db/migrate/20260409163500_initialize_events_table.rb new file mode 100644 index 0000000000000..01347defef071 --- /dev/null +++ b/db/migrate/20260409163500_initialize_events_table.rb @@ -0,0 +1,30 @@ +class InitializeEventsTable < ActiveRecord::Migration[7.0] + def change + create_table :events do |t| + t.string :title, null: false + t.text :description + t.string :primary_stream_url + t.boolean :published, default: false + t.datetime :start_time, null: false + t.datetime :end_time, null: false + t.jsonb :data, default: {} + t.integer :type_of, default: 0 + t.integer :broadcast_config, default: 0 + + t.string :event_name_slug, null: false + t.string :event_variation_slug, null: false + + t.references :user, null: true, foreign_key: true + t.references :organization, foreign_key: true + + t.string :cached_tag_list + t.text :tags_array, default: [], array: true + + t.timestamps + end + + add_check_constraint :events, "broadcast_config IS NOT NULL", name: "events_broadcast_config_null" + add_index :events, [:event_name_slug, :event_variation_slug], unique: true + add_index :events, :tags_array, using: :gin + end +end diff --git a/db/migrate/20260409170801_add_event_id_to_display_ads.rb b/db/migrate/20260409170801_add_event_id_to_display_ads.rb new file mode 100644 index 0000000000000..361fd80519821 --- /dev/null +++ b/db/migrate/20260409170801_add_event_id_to_display_ads.rb @@ -0,0 +1,15 @@ +class AddEventIdToDisplayAds < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def up + add_reference :display_ads, :event, null: true, index: false + add_index :display_ads, :event_id, algorithm: :concurrently, if_not_exists: true + add_foreign_key :display_ads, :events, validate: false + end + + def down + remove_foreign_key :display_ads, :events, if_exists: true + remove_index :display_ads, :event_id, algorithm: :concurrently, if_exists: true + safety_assured { remove_reference :display_ads, :event, null: true, index: false } + end +end diff --git a/db/migrate/20260409173612_validate_event_foreign_key_on_display_ads.rb b/db/migrate/20260409173612_validate_event_foreign_key_on_display_ads.rb new file mode 100644 index 0000000000000..2dca7190587ca --- /dev/null +++ b/db/migrate/20260409173612_validate_event_foreign_key_on_display_ads.rb @@ -0,0 +1,6 @@ +class ValidateEventForeignKeyOnDisplayAds < ActiveRecord::Migration[7.0] + def change + validate_check_constraint :events, name: "events_broadcast_config_null" + validate_foreign_key :display_ads, :events + end +end diff --git a/db/schema.rb b/db/schema.rb index d4e945cb3d609..90fc7171b780b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2026_04_02_165751) do +ActiveRecord::Schema[7.0].define(version: 2026_04_09_173612) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "ltree" @@ -594,6 +594,7 @@ t.string "custom_display_label" t.string "dismissal_sku" t.integer "display_to", default: 0, null: false + t.bigint "event_id" t.integer "exclude_article_ids", default: [], array: true t.string "exclude_role_names", default: [], array: true t.boolean "exclude_survey_completions", default: false, null: false @@ -622,6 +623,7 @@ t.datetime "updated_at", precision: nil, null: false t.float "weight", default: 1.0, null: false t.index ["cached_tag_list"], name: "index_display_ads_on_cached_tag_list", opclass: :gin_trgm_ops, using: :gin + t.index ["event_id"], name: "index_display_ads_on_event_id" t.index ["exclude_article_ids"], name: "index_display_ads_on_exclude_article_ids", using: :gin t.index ["exclude_role_names"], name: "index_display_ads_on_exclude_role_names", using: :gin t.index ["exclude_survey_completions"], name: "idx_display_ads_survey_completions" @@ -666,6 +668,31 @@ t.index ["user_query_id"], name: "index_emails_on_user_query_id" end + create_table "events", force: :cascade do |t| + t.integer "broadcast_config", default: 0 + t.string "cached_tag_list" + t.datetime "created_at", null: false + t.jsonb "data", default: {} + t.text "description" + t.datetime "end_time", null: false + t.string "event_name_slug", null: false + t.string "event_variation_slug", null: false + t.bigint "organization_id" + t.string "primary_stream_url" + t.boolean "published", default: false + t.datetime "start_time", null: false + t.text "tags_array", default: [], array: true + t.string "title", null: false + t.integer "type_of", default: 0 + t.datetime "updated_at", null: false + t.bigint "user_id" + t.index ["event_name_slug", "event_variation_slug"], name: "index_events_on_event_name_slug_and_event_variation_slug", unique: true + t.index ["organization_id"], name: "index_events_on_organization_id" + t.index ["tags_array"], name: "index_events_on_tags_array", using: :gin + t.index ["user_id"], name: "index_events_on_user_id" + t.check_constraint "broadcast_config IS NOT NULL", name: "events_broadcast_config_null" + end + create_table "feed_configs", force: :cascade do |t| t.integer "all_time_tag_count_max", default: 0 t.integer "all_time_tag_count_min", default: 0 @@ -2021,10 +2048,13 @@ add_foreign_key "discussion_locks", "users", column: "locking_user_id" add_foreign_key "display_ad_events", "display_ads", on_delete: :cascade add_foreign_key "display_ad_events", "users", on_delete: :cascade + add_foreign_key "display_ads", "events" add_foreign_key "display_ads", "organizations", on_delete: :cascade add_foreign_key "email_authorizations", "users", on_delete: :cascade add_foreign_key "emails", "audience_segments" add_foreign_key "emails", "user_queries" + add_foreign_key "events", "organizations" + add_foreign_key "events", "users" add_foreign_key "feed_events", "articles", on_delete: :cascade add_foreign_key "feed_events", "users", on_delete: :nullify add_foreign_key "feed_import_items", "articles", on_delete: :nullify diff --git a/db/seeds.rb b/db/seeds.rb index d348b0803208a..074aa575db5e6 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1041,6 +1041,41 @@ # Pin an article for dynamic feed representation first_article = Article.order(Arel.sql("RANDOM()")).first PinnedArticle.set(first_article) if first_article.present? +############################################################################## + +seeder.create_if_none(Event) do + user_ids = User.pluck(:id) + + Event.create!( + title: "AWS Industries LIVE!", + event_name_slug: "aws-industries-live", + event_variation_slug: "v1", + description: "AWS Industries LIVE! features AWS Partners discussing various topics related to their industry, their solutions, and how they can help customers.", + primary_stream_url: "https://player.twitch.tv/?channel=aws&parent=#{Settings::General.app_domain.split(':').first}", + data: { chat_url: "https://www.twitch.tv/embed/aws/chat?parent=#{Settings::General.app_domain.split(':').first}" }, + published: true, + start_time: 1.day.ago, + end_time: 1.week.from_now, + type_of: :live_stream, + user_id: user_ids.sample, + tag_list: "aws" + ) + + Event.create!( + title: "Forem Walkthrough with Ben Halpern", + event_name_slug: "forem-walkthrough-with-ben-halpern", + event_variation_slug: "v1", + description: "Join us for a walkthrough of the newest Forem features.", + primary_stream_url: "https://player.twitch.tv/?channel=ThePracticalDev&parent=#{Settings::General.app_domain.split(':').first}", + data: {}, + published: true, + start_time: 2.days.from_now, + end_time: 2.days.from_now + 2.hours, + type_of: :live_stream, + user_id: user_ids.sample, + tag_list: "forem, updates" + ) +end puts <<-ASCII diff --git a/spec/factories/events.rb b/spec/factories/events.rb new file mode 100644 index 0000000000000..8d9c20049f301 --- /dev/null +++ b/spec/factories/events.rb @@ -0,0 +1,21 @@ +FactoryBot.define do + factory :event do + title { "My Exciting Live Stream" } + sequence(:event_name_slug) { |n| "my-exciting-live-stream-#{n}" } + sequence(:event_variation_slug) { |n| "march-31-#{n}" } + description { "We will be building Forem live on Twitch!" } + primary_stream_url { "https://twitch.tv/ThePracticalDev" } + published { true } + start_time { 1.day.from_now } + end_time { 2.days.from_now } + type_of { "live_stream" } + + trait :takeover do + type_of { "takeover" } + end + + trait :unpublished do + published { false } + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb new file mode 100644 index 0000000000000..ea746a01476d3 --- /dev/null +++ b/spec/models/event_spec.rb @@ -0,0 +1,127 @@ +require "rails_helper" + +RSpec.describe Event, type: :model do + describe "validations" do + # Validations tests have been moved into their dedicated block extending boundaries. + end + + describe "associations" do + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:organization).optional } + end + + describe "enums" do + it do + is_expected.to define_enum_for(:type_of).with_values( + live_stream: 0, + takeover: 1, + other: 2 + ) + end + it do + is_expected.to define_enum_for(:broadcast_config).with_values( + no_broadcast: 0, + tagged_broadcast: 1, + global_broadcast: 2 + ) + end + end + + describe "validations" do + let(:subject) { build(:event, event_name_slug: "test-event", event_variation_slug: "v1") } + + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:start_time) } + it { is_expected.to validate_presence_of(:end_time) } + it { is_expected.to validate_presence_of(:event_name_slug) } + it { is_expected.to validate_presence_of(:event_variation_slug) } + + it "requires uniqueness of event_variation_slug scoped to event_name_slug" do + create(:event, event_name_slug: "test-event", event_variation_slug: "v1") + duplicate = build(:event, event_name_slug: "test-event", event_variation_slug: "v1") + expect(duplicate).not_to be_valid + expect(duplicate.errors[:event_variation_slug]).to include("has already been taken") + + different = build(:event, event_name_slug: "test-event-2", event_variation_slug: "v1") + expect(different).to be_valid + end + + describe "slug formats" do + it "allows valid slugs" do + expect(build(:event, event_name_slug: "valid-1", event_variation_slug: "valid-2")).to be_valid + end + + it "rejects invalid slugs" do + bad_event = build(:event, event_name_slug: "Invalid_1", event_variation_slug: "V 2!") + expect(bad_event).not_to be_valid + expect(bad_event.errors[:event_name_slug]).to be_present + expect(bad_event.errors[:event_variation_slug]).to be_present + end + end + + describe "primary_stream_url format" do + it "allows valid youtube or twitch https URLs" do + expect(build(:event, primary_stream_url: "https://www.youtube.com/watch?v=1234567890a")).to be_valid + expect(build(:event, primary_stream_url: "https://twitch.tv/ThePracticalDev")).to be_valid + end + + it "rejects non-https, XSS, or unknown URLs" do + expect(build(:event, primary_stream_url: "http://twitch.tv/test")).not_to be_valid + expect(build(:event, primary_stream_url: "https://example.com")).not_to be_valid + expect(build(:event, primary_stream_url: "javascript:alert(1)")).not_to be_valid + end + end + end + + describe "#format_stream_urls" do + it "automatically binds chat_url and embedded URLs for Twitch" do + event = create(:event, primary_stream_url: "https://twitch.tv/ThePracticalDev") + expect(event.primary_stream_url).to include("player.twitch.tv/?channel=ThePracticalDev") + expect(event.data["chat_url"]).to include("twitch.tv/embed/ThePracticalDev/chat") + end + + it "automatically binds chat_url and embedded URLs for YouTube" do + event = create(:event, primary_stream_url: "https://youtu.be/abcdefghijk") + expect(event.primary_stream_url).to include("youtube.com/embed/abcdefghijk?autoplay=1") + expect(event.data["chat_url"]).to include("youtube.com/live_chat?v=abcdefghijk") + end + end + + describe "#ensure_broadcast_billboards_and_workers" do + it "does not generate billboards for no_broadcast events" do + event = create(:event, broadcast_config: "no_broadcast") + expect(event.billboards).to be_empty + end + + it "generates fully formulated HTML billboards containing dynamic parameters" do + user = create(:user) + event = create(:event, + broadcast_config: "global_broadcast", + title: "Test HTML Event", + description: "A very exciting summary", + event_name_slug: "test-html-event", + event_variation_slug: "v1", + data: { "image_url" => "https://example.test/img.jpg" }, + user: user) + + # 2 billboards (feed_first, post_fixed_bottom) + expect(event.billboards.count).to eq(2) + + feed_bb = event.billboards.find_by(placement_area: "feed_first") + post_bb = event.billboards.find_by(placement_area: "post_fixed_bottom") + + expect(feed_bb.published).to be(true) + expect(feed_bb.approved).to be(false) # Needs worker to approve + + # Assert the HTML was injected cleanly inside body_markdown using user fallback + expect(feed_bb.body_markdown).to include("id=\"event-takeover-image-feed\"") + expect(feed_bb.body_markdown).to include("Tune in to the full event") + expect(feed_bb.body_markdown).to include("Test HTML Event") + expect(feed_bb.body_markdown).to include("A very exciting summary") + expect(feed_bb.body_markdown).to include("/events/test-html-event/v1") + expect(feed_bb.body_markdown).to include("https://example.test/img.jpg") + + expect(post_bb.body_markdown).to include("id=\"event-takeover-image\"") + end + end +end diff --git a/spec/queries/billboards/filtered_ads_query_spec.rb b/spec/queries/billboards/filtered_ads_query_spec.rb index 7fbf0551ad115..1a3700dbd11d8 100644 --- a/spec/queries/billboards/filtered_ads_query_spec.rb +++ b/spec/queries/billboards/filtered_ads_query_spec.rb @@ -523,4 +523,49 @@ def filter_billboards(**options) end end end + + context "when considering active event broadcasts" do + let(:memory_store) { ActiveSupport::Cache::MemoryStore.new } + + before do + allow(Rails).to receive(:cache).and_return(memory_store) + memory_store.clear + end + + let!(:standard_bb) { create_billboard(placement_area: "feed_first", event_id: nil) } + + context "with an active global broadcast" do + let(:event) { create(:event, start_time: 5.minutes.ago, end_time: 1.hour.from_now, broadcast_config: "global_broadcast", published: true) } + let!(:event_bb) { create_billboard(placement_area: "feed_first", event: event) } + + it "overrides all feed_first placements bypassing standard billboards" do + filtered = filter_billboards(area: "feed_first") + expect(filtered).to include(event_bb) + expect(filtered).not_to include(standard_bb) + end + + it "does not override sidebar placements" do + filtered = filter_billboards(area: "sidebar_left") + expect(filtered).not_to include(event_bb) + end + end + + context "with an active tagged broadcast" do + let(:event) { create(:event, start_time: 5.minutes.ago, end_time: 1.hour.from_now, broadcast_config: "tagged_broadcast", published: true, tags_array: ["javascript"]) } + let!(:event_bb) { create_billboard(placement_area: "feed_first", event: event) } + + it "overrides feed if user tags overlap the event tags" do + filtered = filter_billboards(area: "feed_first", user_tags: ["ruby", "javascript"]) + expect(filtered).to include(event_bb) + expect(filtered).not_to include(standard_bb) + end + + it "does not override feed if user tags do not overlap" do + filtered = filter_billboards(area: "feed_first", user_tags: ["ruby", "python"]) + expect(filtered).not_to include(event_bb) + # Should render standard untagged boards if no mapping aligns securely: + expect(filtered).to include(standard_bb) + end + end + end end diff --git a/spec/requests/admin/events_spec.rb b/spec/requests/admin/events_spec.rb new file mode 100644 index 0000000000000..5e7312ec413dd --- /dev/null +++ b/spec/requests/admin/events_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe "Admin::Events", type: :request do + let(:super_admin) { create(:user, :super_admin) } + let(:regular_user) { create(:user) } + + describe "GET /admin/content_manager/events" do + context "when logged in as an admin" do + before { login_as(super_admin) } + + it "renders the index template" do + create(:event) + get admin_events_path + expect(response).to have_http_status(:success) + end + end + + context "when logged in as a normal user" do + before { login_as(regular_user) } + + it "denies access" do + expect { + get admin_events_path + }.to raise_error(Pundit::NotAuthorizedError) + end + end + end + + describe "POST /admin/content_manager/events" do + before { login_as(super_admin) } + + let(:valid_attributes) do + { + title: "Test Admin Event", + description: "Testing admin creation", + event_name_slug: "test-admin", + event_variation_slug: "v1", + start_time: 1.day.from_now, + end_time: 2.days.from_now, + published: true + } + end + + it "creates a new Event" do + expect { + post admin_events_path, params: { event: valid_attributes } + }.to change(Event, :count).by(1) + + expect(response).to redirect_to(admin_events_path) + expect(Event.last.event_name_slug).to eq("test-admin") + end + end +end diff --git a/spec/requests/api/v0/events_spec.rb b/spec/requests/api/v0/events_spec.rb new file mode 100644 index 0000000000000..16e043b126294 --- /dev/null +++ b/spec/requests/api/v0/events_spec.rb @@ -0,0 +1,103 @@ +require "rails_helper" + +RSpec.describe "Api::V0::Events", type: :request do + let!(:admin) { create(:user).tap { |u| u.add_role(:super_admin) } } + let!(:admin_api_secret) { create(:api_secret, user: admin) } + let!(:admin_headers) { { "api-key" => admin_api_secret.secret, "content-type" => "application/json" } } + + let!(:user) { create(:user) } + let!(:user_api_secret) { create(:api_secret, user: user) } + let!(:user_headers) { { "api-key" => user_api_secret.secret, "content-type" => "application/json" } } + + let!(:published_event) { create(:event, published: true) } + let!(:draft_event) { create(:event, published: false) } + + describe "GET /api/events" do + context "when unauthenticated" do + it "returns only published events" do + get "/api/events" + expect(response).to have_http_status(:success) + + json = JSON.parse(response.body) + expect(json.count).to eq(1) + expect(json.first["id"]).to eq(published_event.id) + end + end + + context "when authenticated as basic user" do + it "returns only published events" do + get "/api/events", headers: user_headers + json = JSON.parse(response.body) + expect(json.count).to eq(1) + end + end + + context "when authenticated as an administrator" do + it "returns all events including drafts" do + get "/api/events", headers: admin_headers + json = JSON.parse(response.body) + expect(json.count).to eq(2) + end + end + end + + describe "GET /api/events/:id" do + context "when requesting a published event" do + it "returns the event" do + get "/api/events/#{published_event.id}" + expect(response).to have_http_status(:success) + end + end + + context "when requesting a draft event" do + it "returns 404 for guests" do + get "/api/events/#{draft_event.id}" + expect(response).to have_http_status(:not_found) + end + + it "returns 404 for basic users" do + get "/api/events/#{draft_event.id}", headers: user_headers + expect(response).to have_http_status(:not_found) + end + + it "returns the event for administrators" do + get "/api/events/#{draft_event.id}", headers: admin_headers + expect(response).to have_http_status(:success) + end + end + end + + describe "POST /api/events" do + let(:valid_params) do + { + event: { + title: "New Stream", + event_name_slug: "new-stream", + event_variation_slug: "v1", + start_time: 1.day.from_now, + end_time: 2.days.from_now, + type_of: "live_stream", + primary_stream_url: "https://twitch.tv/ThePracticalDev", + published: false + } + }.to_json + end + + it "blocks unauthenticated requests" do + post "/api/events", params: valid_params, headers: { "content-type" => "application/json" } + expect(response).to have_http_status(:unauthorized) + end + + it "blocks basic users" do + post "/api/events", params: valid_params, headers: user_headers + expect(response).to have_http_status(:unauthorized) + end + + it "allows administrators to create events" do + expect { + post "/api/events", params: valid_params, headers: admin_headers + }.to change(Event, :count).by(1) + expect(response).to have_http_status(:created) + end + end +end diff --git a/spec/requests/api/v1/billboards_spec.rb b/spec/requests/api/v1/billboards_spec.rb index d31188fa3a569..b222b7c1f7642 100644 --- a/spec/requests/api/v1/billboards_spec.rb +++ b/spec/requests/api/v1/billboards_spec.rb @@ -56,7 +56,7 @@ "exclude_role_names", "target_role_names", "include_subforem_ids", "prefer_paired_with_billboard_id", "custom_display_label", "template", "render_mode", "preferred_article_ids", "priority", "weight", "target_geolocations", "requires_cookies", "special_behavior", "expires_at", - "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array") + "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array", "event_id") expect(response.parsed_body["target_geolocations"]).to contain_exactly("US-WA", "CA-BC") end @@ -78,7 +78,7 @@ "exclude_role_names", "target_role_names", "include_subforem_ids", "custom_display_label", "template", "render_mode", "preferred_article_ids", "priority", "weight", "target_geolocations", "requires_cookies", "special_behavior", "expires_at", - "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array") + "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array", "event_id") expect(response.parsed_body["target_geolocations"]).to contain_exactly("US-WA", "CA-BC") end @@ -146,7 +146,7 @@ "exclude_role_names", "target_role_names", "include_subforem_ids", "custom_display_label", "template", "render_mode", "preferred_article_ids", "priority", "weight", "target_geolocations", "prefer_paired_with_billboard_id", "expires_at", - "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array") + "exclude_survey_completions", "exclude_survey_ids", "content_updated_at", "tags_array", "event_id") end it "also accepts target geolocations as an array" do diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb new file mode 100644 index 0000000000000..524d6d50be22e --- /dev/null +++ b/spec/requests/events_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +RSpec.describe "Events", type: :request do + let!(:published_event) { create(:event, title: "Super Cool Launch Event", published: true) } + let!(:draft_event) { create(:event, title: "Secret Internal Test", published: false) } + + describe "GET /events" do + it "renders the index successfully, displaying only published events" do + get events_path + + expect(response).to have_http_status(:success) + expect(response.body).to include(published_event.title) + expect(response.body).not_to include(draft_event.title) + end + end + + describe "GET /events/:id" do + context "when requesting a published event" do + it "renders the show view successfully" do + # `event_path` natively uses the overloaded `to_param` (slug) we built! + get event_path(published_event.event_name_slug, published_event.event_variation_slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include(published_event.title) + end + end + + context "when requesting a draft event" do + it "raises a 404 RoutingError as if it does not exist" do + expect { + get event_path(draft_event.event_name_slug, draft_event.event_variation_slug) + }.to raise_error(ActionController::RoutingError, "Not Found") + end + end + + context "when an event does not exist" do + it "raises an ActiveRecord::RecordNotFound implicitly handled as a 404" do + expect { + get "/events/does-not-exist/version" + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/workers/events/manage_broadcast_billboards_worker_spec.rb b/spec/workers/events/manage_broadcast_billboards_worker_spec.rb new file mode 100644 index 0000000000000..8c7054c4403fb --- /dev/null +++ b/spec/workers/events/manage_broadcast_billboards_worker_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe Events::ManageBroadcastBillboardsWorker, type: :worker do + describe "#perform" do + let(:past_event) do + create(:event, start_time: 2.hours.ago, end_time: 1.hour.ago, broadcast_config: "global_broadcast", published: true) + end + + let(:active_event) do + create(:event, start_time: 5.minutes.ago, end_time: 1.hour.from_now, broadcast_config: "tagged_broadcast", published: true) + end + + let(:future_event) do + create(:event, start_time: 1.hour.from_now, end_time: 2.hours.from_now, broadcast_config: "global_broadcast", published: true) + end + + let(:no_broadcast_event) do + create(:event, start_time: 5.minutes.ago, end_time: 1.hour.from_now, broadcast_config: "no_broadcast", published: true) + end + + let!(:past_billboard) { create(:billboard, event: past_event, approved: true, published: true) } + let!(:active_billboard) { create(:billboard, event: active_event, approved: false, published: true) } + let!(:future_billboard) { create(:billboard, event: future_event, approved: false, published: true) } + let!(:no_broadcast_billboard) { create(:billboard, event: no_broadcast_event, approved: true, published: true) } # should unapprove if somehow associated + let!(:standard_billboard) { create(:billboard, event: nil, approved: true) } + + it "approves billboards for active broadcast events and unapproves for inactive ones" do + described_class.new.perform + + expect(active_billboard.reload.approved).to eq(true) + expect(past_billboard.reload.approved).to eq(false) + expect(future_billboard.reload.approved).to eq(false) + + # Standard billboards remain untouched + expect(standard_billboard.reload.approved).to eq(true) + end + + it "purges edge caches when toggling statuses" do + allow(EdgeCache::PurgeByKey).to receive(:call) + + described_class.new.perform + + expect(EdgeCache::PurgeByKey).to have_received(:call).with("main_app_home_page", fallback_paths: "/").at_least(:once) + expect(EdgeCache::PurgeByKey).to have_received(:call).with(active_billboard.record_key).once + expect(EdgeCache::PurgeByKey).to have_received(:call).with(past_billboard.record_key).once + expect(EdgeCache::PurgeByKey).to have_received(:call).with(no_broadcast_billboard.record_key).once + end + + context "when right at the border constraints" do + let(:border_start_event) do + create(:event, start_time: 14.minutes.from_now, end_time: 2.hours.from_now, broadcast_config: "global_broadcast", published: true) + end + + let(:border_end_event) do + create(:event, start_time: 2.hours.ago, end_time: 6.minutes.ago, broadcast_config: "global_broadcast", published: true) + end + + let!(:border_start_bb) { create(:billboard, event: border_start_event, approved: false) } + let!(:border_end_bb) { create(:billboard, event: border_end_event, approved: true) } + + it "approves exactly within 15 bounds, and disables exactly after 5 boundary limits" do + described_class.new.perform + expect(border_start_bb.reload.approved).to eq(true) # <= 15.minutes + expect(border_end_bb.reload.approved).to eq(false) # <= -5.minutes (6.minutes.ago is completely expired) + end + end + end +end From 2feb2cb20c324f0eaac838f5abac9ba907379d05 Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Thu, 9 Apr 2026 19:58:30 -0400 Subject: [PATCH 2/2] Add placement area config automation for events (#23117) * Add placement area config automation for events * Update app/models/event.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update app/models/billboard_placement_area_config.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix method * select tags --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/models/billboard_placement_area_config.rb | 4 ++++ app/models/event.rb | 10 +++++++++ app/queries/billboards/filtered_ads_query.rb | 7 +----- .../billboard_placement_area_config_spec.rb | 22 +++++++++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/models/billboard_placement_area_config.rb b/app/models/billboard_placement_area_config.rb index cdc9fd3c7d897..1f9e6189dbe17 100644 --- a/app/models/billboard_placement_area_config.rb +++ b/app/models/billboard_placement_area_config.rb @@ -38,6 +38,10 @@ class BillboardPlacementAreaConfig < ApplicationRecord # Get delivery rate for a specific placement area and user state def self.delivery_rate_for(placement_area:, user_signed_in:) + if ["feed_first", "post_fixed_bottom"].include?(placement_area) && Event.active_broadcast_events.any? + return 100 + end + config = config_for_placement_area(placement_area) return 100 if config.blank? # Default to 100% if no config exists diff --git a/app/models/event.rb b/app/models/event.rb index ae4ee72202cad..e40769a471505 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -23,6 +23,16 @@ class Event < ApplicationRecord scope :published, -> { where(published: true) } + def self.active_broadcast_events + Rails.cache.fetch("active_broadcast_events", expires_in: 30.seconds) do + published + .where.not(broadcast_config: "no_broadcast") + .where("start_time <= ? AND end_time >= ?", Time.current + 15.minutes, Time.current - 5.minutes) + .select(:id, :broadcast_config, :start_time, :end_time, :tags_array) + .to_a + end + end + private def end_time_after_start_time diff --git a/app/queries/billboards/filtered_ads_query.rb b/app/queries/billboards/filtered_ads_query.rb index 01f8d52170f88..93800dbd779c6 100644 --- a/app/queries/billboards/filtered_ads_query.rb +++ b/app/queries/billboards/filtered_ads_query.rb @@ -97,12 +97,7 @@ def call def apply_event_broadcast_overrides! if @area.in?(%w[feed_first post_fixed_bottom]) - active_events = Rails.cache.fetch("active_broadcast_event_for_#{@area}", expires_in: 30.seconds) do - Event.published - .where.not(broadcast_config: "no_broadcast") - .where("start_time <= ? AND end_time >= ?", Time.current + 15.minutes, Time.current - 5.minutes) - .to_a - end + active_events = Event.active_broadcast_events verified_event_ids = [] active_events.each do |active_event| diff --git a/spec/models/billboard_placement_area_config_spec.rb b/spec/models/billboard_placement_area_config_spec.rb index bc53419ee35aa..d83ff9a1dd6b1 100644 --- a/spec/models/billboard_placement_area_config_spec.rb +++ b/spec/models/billboard_placement_area_config_spec.rb @@ -79,6 +79,28 @@ describe ".delivery_rate_for" do let!(:config) { described_class.create!(placement_area: "sidebar_left", signed_in_rate: 80, signed_out_rate: 60) } + context "with active broadcast events" do + before do + allow(Event).to receive(:active_broadcast_events).and_return([double("event")]) + end + + it "returns 100 for feed_first regardless of config" do + described_class.create!(placement_area: "feed_first", signed_in_rate: 10, signed_out_rate: 0) + expect(described_class.delivery_rate_for(placement_area: "feed_first", user_signed_in: true)).to eq(100) + expect(described_class.delivery_rate_for(placement_area: "feed_first", user_signed_in: false)).to eq(100) + end + + it "returns 100 for post_fixed_bottom regardless of config" do + described_class.create!(placement_area: "post_fixed_bottom", signed_in_rate: 5, signed_out_rate: 5) + expect(described_class.delivery_rate_for(placement_area: "post_fixed_bottom", user_signed_in: true)).to eq(100) + expect(described_class.delivery_rate_for(placement_area: "post_fixed_bottom", user_signed_in: false)).to eq(100) + end + + it "does not override other placement areas" do + expect(described_class.delivery_rate_for(placement_area: "sidebar_left", user_signed_in: true)).to eq(80) + end + end + it "returns the signed_in_rate for signed in users" do rate = described_class.delivery_rate_for(placement_area: "sidebar_left", user_signed_in: true) expect(rate).to eq(80)