Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/admin/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def event_params
:event_name_slug,
:event_variation_slug,
:description,
:full_details,
:primary_stream_url,
:published,
:elevated,
Expand Down
31 changes: 18 additions & 13 deletions app/controllers/api/v0/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ def index
unless @user&.administrative_access_to?(resource: Event)
@events = @events.published
end
if params[:type_of].present? && Event.type_ofs.key?(params[:type_of])
@events = @events.where(type_of: params[:type_of])
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

Expand Down Expand Up @@ -57,9 +61,9 @@ def destroy
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
return unless request.headers["api-key"]

authenticate!
end

def set_event
Expand All @@ -70,19 +74,20 @@ def set_event

def event_params
params.require(:event).permit(
:title,
:title,
:event_name_slug,
:event_variation_slug,
:description,
:primary_stream_url,
:published,
:start_time,
:end_time,
:type_of,
:user_id,
:organization_id,
:description,
:full_details,
:primary_stream_url,
:published,
:start_time,
:end_time,
:type_of,
:user_id,
:organization_id,
:tag_list,
data: {}
data: {},
)
end
end
Expand Down
20 changes: 20 additions & 0 deletions app/mailers/custom_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ def custom_email
return if ForemInstance.customerio_email_cutover?

@user = params[:user]

# That guard is global, but :customerio_email_delivery rolls out per actor:
# while it is partially on, the enabled cohort is already receiving this
# broadcast/newsletter/drip from the Customer.io side. Sending it from here
# too would deliver it twice to exactly those people. Emails::BatchCustomSendWorker
# skips them before we get here; this backstop also covers the drip worker,
# which builds its sends itself.
return if customerio_managed_recipient?

@content = Email.replace_merge_tags(params[:content], @user)
@subject = Email.replace_merge_tags(params[:subject], @user)
@unsubscribe = generate_unsubscribe_token(@user.id, :email_newsletter)
Expand All @@ -41,4 +50,15 @@ def custom_email

mail(to: @user.email, subject: @subject, from: email_from(@from_topic))
end

private

# Test sends are exempt: nothing on the Customer.io side duplicates them, and
# admins still need the preview while the flag is rolling out.
def customerio_managed_recipient?
return false unless ForemInstance.customerio_enabled?
return false if params[:subject].to_s.start_with?(Email::TEST_SUBJECT_PREFIX)

FeatureFlag.enabled_for_user?(Deliverable::CUSTOMERIO_FLAG, @user)
end
end
8 changes: 6 additions & 2 deletions app/models/email.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
class Email < ApplicationRecord
# Test sends are the same broadcast with a marked subject. Several guards key
# off it, so keep the marker in one place.
TEST_SUBJECT_PREFIX = "[TEST] ".freeze

belongs_to :audience_segment, optional: true
belongs_to :user_query, optional: true
belongs_to :event, optional: true
Expand Down Expand Up @@ -90,8 +94,8 @@ def deliver_to_test_emails(addresses_string)
users_batch = User.where(email: email_array)
return if users_batch.empty?

Emails::BatchCustomSendWorker.perform_async(users_batch.map(&:id), "[TEST] #{subject}", body, type_of, id,
default_from_name_based_on_type)
Emails::BatchCustomSendWorker.perform_async(users_batch.map(&:id), "#{TEST_SUBJECT_PREFIX}#{subject}", body,
type_of, id, default_from_name_based_on_type)
end

def deliver_to_users
Expand Down
11 changes: 8 additions & 3 deletions app/services/delivery_methods/customer_io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@ def deliver!(mail)
def build_message(mail)
{}.tap do |message|
# With a transactional_message_id the Customer.io template renders the
# content; without one this is a body passthrough send.
message[:body] = build_body(mail) unless settings[:transactional_message_id]
message[:from] = mail.from.first if mail.from
# content; without one this is a body passthrough send. The App API
# treats body/from in the request as overrides of what the template
# already defines, so the template's own sender identity only survives
# if we leave both out.
unless settings[:transactional_message_id]
message[:body] = build_body(mail)
message[:from] = mail.from.first if mail.from
end
message[:subject] = mail.subject if mail.subject
message[:identifiers] = { email: mail.to.first } if mail.to
message[:reply_to] = mail.reply_to.first if mail.reply_to
Expand Down
10 changes: 10 additions & 0 deletions app/views/admin/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
<%= form.text_area :description, class: "crayons-textfield", rows: 3 %>
</div>

<div class="crayons-field">
<%= form.label :full_details, "Full Details (API Only / Agent Context)", class: "crayons-field__label" do %>
Full Details (API Only / Agent Context)
<p class="crayons-field__description">
Please dump <strong>all</strong> comprehensive details, schedule, notes, speaker bios, submission guidelines, rules, FAQs, and background context here. This field is exposed exclusively in the API for AI agents and integrations, and will <strong>not</strong> be displayed on the public event page.
</p>
<% end %>
<%= form.text_area :full_details, class: "crayons-textfield", rows: 8, placeholder: "Provide comprehensive event details, full schedule, rules, speaker bios, FAQs, and agent context..." %>
</div>

<div class="crayons-field">
<%= form.label :cover_image, "Cover Image", class: "crayons-field__label" do %>
Cover Image
Expand Down
11 changes: 11 additions & 0 deletions app/views/admin/events/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@
</ul>
</div>

<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Full Details (API Only / Agent Context)</h2>
<% if @event.full_details.present? %>
<div class="p-4 bg-base-10 rounded border border-base-20" style="white-space: pre-wrap; font-family: monospace; font-size: var(--fs-s); line-height: 1.6; max-height: 400px; overflow-y: auto;">
<%= @event.full_details %>
</div>
<% else %>
<p class="color-base-60 italic">No full details dump provided yet.</p>
<% end %>
</div>

<hr class="my-8" />

<div class="flex items-center justify-between mb-6">
Expand Down
26 changes: 25 additions & 1 deletion app/workers/emails/batch_custom_send_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ def perform(user_ids, subject, content, type_of, email_id, from_name = nil)
.select(:id, :email, :name, :username)
.index_by(&:id)

test_send = subject.start_with?(Email::TEST_SUBJECT_PREFIX)

# Skip recipients Customer.io is already sending this broadcast to.
customerio_user_ids = test_send ? Set.new : customerio_managed_user_ids(users_by_id.values)

# Bulk check: skip users who already received a non-test email for this email_id.
# Uses a subquery with DISTINCT ON to get the most recent message per user,
# then filters out [TEST] subjects — all in a single SQL round-trip.
already_sent_user_ids = if subject.start_with?("[TEST] ")
already_sent_user_ids = if test_send
Set.new
else
sql = Ahoy::Message.sanitize_sql_array([<<~SQL.squish, user_ids, email_id])
Expand All @@ -40,6 +45,7 @@ def perform(user_ids, subject, content, type_of, email_id, from_name = nil)
user = users_by_id[id]
next unless user
next if already_sent_user_ids.include?(id)
next if customerio_user_ids.include?(id)

CustomMailer
.with(
Expand All @@ -56,5 +62,23 @@ def perform(user_ids, subject, content, type_of, email_id, from_name = nil)
Rails.logger.error("Error sending email to user with id: #{id}. Error: #{e.message}")
end
end

private

# The cutover guards in Email, this worker and Admin::EmailsController all
# key off the *global* flag state, but :customerio_email_delivery rolls out
# per actor. While it is partially on, the enabled cohort already receives
# broadcasts and newsletters from the Customer.io side, so sending from here
# too would deliver the same message twice to exactly those people.
#
# Test sends are exempt (see #perform): nothing on the Customer.io side
# duplicates them, and admins still need the preview during the rollout.
def customerio_managed_user_ids(users)
return Set.new unless ForemInstance.customerio_enabled?

users.each_with_object(Set.new) do |user, ids|
ids << user.id if FeatureFlag.enabled_for_user?(Deliverable::CUSTOMERIO_FLAG, user)
end
end
end
end
5 changes: 5 additions & 0 deletions db/migrate/20260825100500_add_full_details_to_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddFullDetailsToEvents < ActiveRecord::Migration[8.0]
def change
add_column :events, :full_details, :text
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.0].define(version: 2026_08_21_120000) do
ActiveRecord::Schema[8.0].define(version: 2026_08_25_100500) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "ltree"
Expand Down Expand Up @@ -785,6 +785,7 @@
t.datetime "end_time", null: false
t.string "event_name_slug", null: false
t.string "event_variation_slug", null: false
t.text "full_details"
t.boolean "manual_broadcast_end", default: false, null: false
t.bigint "organization_id"
t.bigint "page_id"
Expand Down
41 changes: 41 additions & 0 deletions spec/mailers/custom_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,47 @@
end
end

# Emails::BatchCustomSendWorker filters these recipients out first; this is
# the backstop for senders that build their own sends (the drip worker).
context "when the recipient has the Customer.io delivery flag enabled" do
let(:email) { create(:email, type_of: "newsletter") }

before do
allow(ForemInstance).to receive_messages(smtp_enabled?: true, customerio_enabled?: true)
allow(FeatureFlag).to receive(:enabled_for_user?)
.with(Deliverable::CUSTOMERIO_FLAG, having_attributes(id: user.id)).and_return(true)
end

it "sends nothing -- Customer.io is already sending this broadcast" do
expect do
described_class.with(
user: user, content: content, subject: subject, email_id: email.id,
).custom_email.deliver_now
end.not_to change(ActionMailer::Base.deliveries, :count)
end

it "does not record an ahoy message" do
expect do
described_class.with(
user: user, content: content, subject: subject, email_id: email.id,
).custom_email.deliver_now
end.not_to change(EmailMessage, :count)
end

it "still sends a test email so admins can preview during the rollout" do
# The recipient is flagged, so the send routes through Customer.io
# rather than landing in ActionMailer::Base.deliveries.
api_client = instance_double(Customerio::APIClient, send_email: { "delivery_id" => "dev-123" })
stub_const("CUSTOMERIO_API", api_client)

described_class.with(
user: user, content: content, subject: "[TEST] #{subject}", email_id: email.id,
).custom_email.deliver_now

expect(api_client).to have_received(:send_email)
end
end

context "when SendGrid is disabled" do
before do
allow(ForemInstance).to receive(:sendgrid_enabled?).and_return(false)
Expand Down
40 changes: 31 additions & 9 deletions spec/requests/admin/events_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,16 @@

get admin_event_path(event)
expect(response).to have_http_status(:success)
expect(response.body).to include(event.title)
expect(response.body).to include("Alice Smith")
expect(response.body).to include("@alicesmith")
expect(response.body).to include("Bob Jones")
expect(response.body).to include("@bobjones")
expect(response.body).to include("1 Day Before")
expect(response.body).to include("1 Hour Before")
expect(response.body).to include(event.title, "Alice Smith", "@alicesmith", "Bob Jones", "@bobjones")
expect(response.body).to include("1 Day Before", "1 Hour Before")
end

it "renders full_details when present on the event" do
event.update!(full_details: "Detailed breakdown of agenda, speakers, and schedule")

get admin_event_path(event)
expect(response).to have_http_status(:success)
expect(response.body).to include("Detailed breakdown of agenda, speakers, and schedule")
end

it "renders the show template with fallback message when there are no signups" do
Expand Down Expand Up @@ -165,6 +168,17 @@
end
end

context "with full_details config" do
let(:attributes_with_details) do
valid_attributes.merge(full_details: "Comprehensive agenda and agent context notes")
end

it "permits and sets the full_details attribute" do
post admin_events_path, params: { event: attributes_with_details }
expect(Event.last.full_details).to eq("Comprehensive agenda and agent context notes")
end
end

context "with bg_color_hex" do
let(:attributes_with_hex) { valid_attributes.merge(bg_color_hex: "#7C3AED") }

Expand All @@ -181,16 +195,22 @@
context "when logged in as an admin" do
before { login_as(super_admin) }

it "updates the event title, cover image, and bg_color_hex" do
it "updates the event title, cover image, bg_color_hex, and full_details" do
image_file = fixture_file_upload(Rails.root.join("spec/fixtures/files/800x600.png"), "image/png")
patch admin_event_path(event), params: {
event: { title: "Updated Event Title", cover_image: image_file, bg_color_hex: "#0D9488" }
event: {
title: "Updated Event Title",
cover_image: image_file,
bg_color_hex: "#0D9488",
full_details: "Updated comprehensive agenda details"
}
}

expect(response).to redirect_to(admin_events_path)
expect(event.reload.title).to eq("Updated Event Title")
expect(event.cover_image).to be_present
expect(event.bg_color_hex).to eq("#0D9488")
expect(event.full_details).to eq("Updated comprehensive agenda details")
end

it "removes the cover image when remove_cover_image is submitted" do
Expand Down Expand Up @@ -236,13 +256,15 @@

it "pre-fills the form with attributes from the original event when fork_from_id is passed" do
original_event = create(:event, title: "Original Event Title", description: "Original Description",
full_details: "Original Full Details Dump",
tag_list: %w[ruby rails])

get new_admin_event_path(fork_from_id: original_event.id)

expect(response).to have_http_status(:success)
expect(response.body).to include("Original Event Title")
expect(response.body).to include("Original Description")
expect(response.body).to include("Original Full Details Dump")
expect(response.body).to include("ruby, rails")
end
end
Expand Down
Loading
Loading