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/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
new file mode 100644
index 0000000000000..e40769a471505
--- /dev/null
+++ b/app/models/event.rb
@@ -0,0 +1,246 @@
+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) }
+
+ 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
+ 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}
+
+
+ #{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}
+
+
+
+
+
+ #{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..93800dbd779c6 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,34 @@ def call
private
+ def apply_event_broadcast_overrides!
+ if @area.in?(%w[feed_first post_fixed_bottom])
+ active_events = Event.active_broadcast_events
+
+ 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" %>
+
+
+
+
+
+ Title
+ Start Time
+ Type
+ Status
+ Actions
+
+
+
+ <% @events.each do |event| %>
+
+ <%= 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" %>
+
+
+
+ <% end %>
+
+
+
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 @@
+
+
+
+ <% 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 @@
+
+
+
+
+
+
+
+ <% 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 %>
+
+
+
+
+ <% tag_name = @event.tags.first&.name %>
+
+ <% if @articles.any? %>
+
+
+
+ <% @articles.each do |article| %>
+
+
+
+
<%= article.user.name %>
+
+
+ <%= article.title %>
+
+
+ <% article.cached_tag_list_array.take(3).each do |tag| %>
+ #<%= tag %>
+ <% end %>
+
+
+ <%= article.published_at&.strftime("%b %-d") %>
+
+
+ <% 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/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)
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