From 9fbabc9155e71021b5bd30b508498dadb466d01c Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Tue, 25 Aug 2026 09:52:32 -0300 Subject: [PATCH 1/3] Add type_of query parameter filtering to events API endpoint (#23772) --- app/controllers/api/v0/events_controller.rb | 30 +- spec/requests/api/v0/events_spec.rb | 53 ++- spec/requests/api/v1/docs/events_spec.rb | 149 ++++++++ spec/swagger_helper.rb | 53 +++ swagger/v1/api_v1.json | 363 ++++++++++++++++++++ 5 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 spec/requests/api/v1/docs/events_spec.rb diff --git a/app/controllers/api/v0/events_controller.rb b/app/controllers/api/v0/events_controller.rb index 9f884a849285b..a4d7015acf41e 100644 --- a/app/controllers/api/v0/events_controller.rb +++ b/app/controllers/api/v0/events_controller.rb @@ -14,6 +14,9 @@ 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 @@ -21,6 +24,7 @@ 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 @@ -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 @@ -70,19 +74,19 @@ 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, + :primary_stream_url, + :published, + :start_time, + :end_time, + :type_of, + :user_id, + :organization_id, :tag_list, - data: {} + data: {}, ) end end diff --git a/spec/requests/api/v0/events_spec.rb b/spec/requests/api/v0/events_spec.rb index 16e043b126294..01031919112b5 100644 --- a/spec/requests/api/v0/events_spec.rb +++ b/spec/requests/api/v0/events_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Api::V0::Events", type: :request do +RSpec.describe "Api::V0::Events" 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" } } @@ -17,8 +17,8 @@ it "returns only published events" do get "/api/events" expect(response).to have_http_status(:success) - - json = JSON.parse(response.body) + + json = response.parsed_body expect(json.count).to eq(1) expect(json.first["id"]).to eq(published_event.id) end @@ -27,7 +27,7 @@ context "when authenticated as basic user" do it "returns only published events" do get "/api/events", headers: user_headers - json = JSON.parse(response.body) + json = response.parsed_body expect(json.count).to eq(1) end end @@ -35,10 +35,49 @@ 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) + json = response.parsed_body expect(json.count).to eq(2) end end + + context "when filtering by type_of" do + let!(:challenge_event) { create(:event, published: true, type_of: :challenge) } + let!(:draft_challenge_event) { create(:event, published: false, type_of: :challenge) } + let!(:stream_event) { create(:event, published: true, type_of: :live_stream) } + + it "returns only events matching the requested type_of when unauthenticated" do + get "/api/events", params: { type_of: "challenge" } + expect(response).to have_http_status(:success) + + json = response.parsed_body + expect(json.pluck("id")).to contain_exactly(challenge_event.id) + end + + it "returns only events matching the requested type_of for basic users" do + get "/api/events", params: { type_of: "live_stream" }, headers: user_headers + expect(response).to have_http_status(:success) + + json = response.parsed_body + expect(json.pluck("id")).to include(stream_event.id) + expect(json.pluck("id")).not_to include(challenge_event.id) + end + + it "returns matching events including drafts for administrators" do + get "/api/events", params: { type_of: "challenge" }, headers: admin_headers + expect(response).to have_http_status(:success) + + json = response.parsed_body + expect(json.pluck("id")).to contain_exactly(challenge_event.id, draft_challenge_event.id) + end + + it "ignores invalid type_of values" do + get "/api/events", params: { type_of: "nonexistent_type" } + expect(response).to have_http_status(:success) + + json = response.parsed_body + expect(json.pluck("id")).to include(published_event.id, challenge_event.id, stream_event.id) + end + end end describe "GET /api/events/:id" do @@ -94,9 +133,9 @@ end it "allows administrators to create events" do - expect { + expect do post "/api/events", params: valid_params, headers: admin_headers - }.to change(Event, :count).by(1) + end.to change(Event, :count).by(1) expect(response).to have_http_status(:created) end end diff --git a/spec/requests/api/v1/docs/events_spec.rb b/spec/requests/api/v1/docs/events_spec.rb new file mode 100644 index 0000000000000..59c07b6356f29 --- /dev/null +++ b/spec/requests/api/v1/docs/events_spec.rb @@ -0,0 +1,149 @@ +require "rails_helper" +require "swagger_helper" + +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable RSpec/VariableName + +RSpec.describe "Api::V1::Docs::Events" do + let(:admin) { create(:user, :super_admin) } + let(:admin_api_secret) { create(:api_secret, user: admin) } + let!(:event) { create(:event, published: true, type_of: :challenge) } + + describe "GET /api/events" do + path "/api/events" do + get "Retrieve events" do + tags "events" + security [] + description "Retrieve a list of events on the platform. + +### Query Parameters: +- **type_of**: Filter events by their type (`live_stream`, `takeover`, `other`, `challenge`)." + operationId "getEvents" + produces "application/json" + parameter name: :type_of, + in: :query, + required: false, + description: "Filter events by type.", + schema: { + type: :string, + enum: %w[live_stream takeover other challenge] + } + + response "200", "A list of events" do + let(:type_of) { "challenge" } + schema type: :array, items: { "$ref": "#/components/schemas/Event" } + add_examples + + run_test! + end + end + + post "Create an event" do + tags "events" + description "Create a new event. Requires administrator privileges." + operationId "createEvent" + consumes "application/json" + produces "application/json" + parameter name: :event_params, + in: :body, + description: "Event parameters to create.", + schema: { "$ref": "#/components/schemas/EventInput" } + + response "201", "Event created" do + let(:"api-key") { admin_api_secret.secret } + let(:event_params) do + { + event: { + title: "Community Challenge", + event_name_slug: "community-challenge", + event_variation_slug: "2026", + start_time: 1.day.from_now.iso8601, + end_time: 2.days.from_now.iso8601, + type_of: "challenge", + published: true + } + } + end + schema "$ref": "#/components/schemas/Event" + add_examples + + run_test! + end + end + end + end + + describe "GET /api/events/{id}" do + path "/api/events/{id}" do + get "Retrieve an event" do + tags "events" + security [] + description "Retrieve a single event by ID." + operationId "getEventById" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "ID of the event.", + schema: { type: :integer } + + response "200", "The requested event" do + let(:id) { event.id } + schema "$ref": "#/components/schemas/Event" + add_examples + + run_test! + end + end + + patch "Update an event" do + tags "events" + description "Update an existing event. Requires administrator privileges." + operationId "updateEvent" + consumes "application/json" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "ID of the event to update.", + schema: { type: :integer } + parameter name: :event_params, + in: :body, + description: "Event parameters to update.", + schema: { "$ref": "#/components/schemas/EventInput" } + + response "200", "Event updated" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { event.id } + let(:event_params) do + { + event: { + title: "Updated Challenge Title" + } + } + end + schema "$ref": "#/components/schemas/Event" + add_examples + + run_test! + end + end + + delete "Delete an event" do + tags "events" + description "Delete an event. Requires administrator privileges." + operationId "deleteEvent" + parameter name: :id, in: :path, required: true, + description: "ID of the event to delete.", + schema: { type: :integer } + + response "204", "Event deleted" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { event.id } + add_examples + + run_test! + end + end + end + end +end + +# rubocop:enable RSpec/VariableName +# rubocop:enable RSpec/EmptyExampleGroup diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 956506f3aa24c..86013912fda8c 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -853,6 +853,59 @@ cover_image_url: { type: :string } }, required: %w[id domain root name description logo_image_url cover_image_url] + }, + Event: { + description: "Representation of an event", + type: :object, + properties: { + id: { type: :integer, format: :int64 }, + title: { type: :string }, + event_name_slug: { type: :string }, + event_variation_slug: { type: :string }, + description: { type: :string, nullable: true }, + type_of: { type: :string, enum: %w[live_stream takeover other challenge] }, + start_time: { type: :string, format: "date-time" }, + end_time: { type: :string, format: "date-time" }, + published: { type: :boolean }, + primary_stream_url: { type: :string, nullable: true }, + bg_color_hex: { type: :string, nullable: true }, + broadcast_config: { type: :string, enum: %w[no_broadcast tagged_broadcast global_broadcast] }, + broadcast_ended_at: { type: :string, format: "date-time", nullable: true }, + user_id: { type: :integer, format: :int64, nullable: true }, + organization_id: { type: :integer, format: :int64, nullable: true }, + page_id: { type: :integer, format: :int64, nullable: true }, + data: { type: :object, nullable: true }, + tags_array: { type: :array, items: { type: :string } }, + cached_tag_list: { type: :string, nullable: true }, + created_at: { type: :string, format: "date-time" }, + updated_at: { type: :string, format: "date-time" } + }, + required: %w[id title event_name_slug event_variation_slug type_of start_time end_time published created_at updated_at] + }, + EventInput: { + description: "Representation of an Event to be created/updated", + type: :object, + properties: { + event: { + type: :object, + properties: { + title: { type: :string }, + event_name_slug: { type: :string }, + event_variation_slug: { type: :string }, + description: { type: :string, nullable: true }, + primary_stream_url: { type: :string, nullable: true }, + published: { type: :boolean, default: false }, + start_time: { type: :string, format: "date-time" }, + end_time: { type: :string, format: "date-time" }, + type_of: { type: :string, enum: %w[live_stream takeover other challenge] }, + organization_id: { type: :integer, nullable: true }, + tag_list: { type: :string, nullable: true }, + data: { type: :object, nullable: true } + }, + required: %w[title event_name_slug event_variation_slug start_time end_time] + } + }, + required: %w[event] } } } diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 595b0f827d305..38234de483109 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -2684,6 +2684,185 @@ } } }, + "/api/events": { + "get": { + "summary": "Retrieve events", + "tags": [ + "events" + ], + "security": [ + + ], + "description": "Retrieve a list of events on the platform.\n\n### Query Parameters:\n- **type_of**: Filter events by their type (`live_stream`, `takeover`, `other`, `challenge`).", + "operationId": "getEvents", + "parameters": [ + { + "name": "type_of", + "in": "query", + "required": false, + "description": "Filter events by type.", + "schema": { + "type": "string", + "enum": [ + "live_stream", + "takeover", + "other", + "challenge" + ] + } + } + ], + "responses": { + "200": { + "description": "A list of events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } + } + } + } + } + } + }, + "post": { + "summary": "Create an event", + "tags": [ + "events" + ], + "description": "Create a new event. Requires administrator privileges.", + "operationId": "createEvent", + "parameters": [ + + ], + "responses": { + "201": { + "description": "Event created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventInput" + } + } + }, + "description": "Event parameters to create." + } + } + }, + "/api/events/{id}": { + "get": { + "summary": "Retrieve an event", + "tags": [ + "events" + ], + "security": [ + + ], + "description": "Retrieve a single event by ID.", + "operationId": "getEventById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the event.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The requested event", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + } + }, + "patch": { + "summary": "Update an event", + "tags": [ + "events" + ], + "description": "Update an existing event. Requires administrator privileges.", + "operationId": "updateEvent", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the event to update.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Event updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventInput" + } + } + }, + "description": "Event parameters to update." + } + }, + "delete": { + "summary": "Delete an event", + "tags": [ + "events" + ], + "description": "Delete an event. Requires administrator privileges.", + "operationId": "deleteEvent", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the event to delete.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Event deleted" + } + } + } + }, "/api/feedback_messages/{id}": { "patch": { "summary": "Update a feedback message's status (Admin)", @@ -7746,6 +7925,190 @@ "logo_image_url", "cover_image_url" ] + }, + "Event": { + "description": "Representation of an event", + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "event_name_slug": { + "type": "string" + }, + "event_variation_slug": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "type_of": { + "type": "string", + "enum": [ + "live_stream", + "takeover", + "other", + "challenge" + ] + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "published": { + "type": "boolean" + }, + "primary_stream_url": { + "type": "string", + "nullable": true + }, + "bg_color_hex": { + "type": "string", + "nullable": true + }, + "broadcast_config": { + "type": "string", + "enum": [ + "no_broadcast", + "tagged_broadcast", + "global_broadcast" + ] + }, + "broadcast_ended_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "user_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "organization_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "page_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "data": { + "type": "object", + "nullable": true + }, + "tags_array": { + "type": "array", + "items": { + "type": "string" + } + }, + "cached_tag_list": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "title", + "event_name_slug", + "event_variation_slug", + "type_of", + "start_time", + "end_time", + "published", + "created_at", + "updated_at" + ] + }, + "EventInput": { + "description": "Representation of an Event to be created/updated", + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "event_name_slug": { + "type": "string" + }, + "event_variation_slug": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "primary_stream_url": { + "type": "string", + "nullable": true + }, + "published": { + "type": "boolean", + "default": false + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "type_of": { + "type": "string", + "enum": [ + "live_stream", + "takeover", + "other", + "challenge" + ] + }, + "organization_id": { + "type": "integer", + "nullable": true + }, + "tag_list": { + "type": "string", + "nullable": true + }, + "data": { + "type": "object", + "nullable": true + } + }, + "required": [ + "title", + "event_name_slug", + "event_variation_slug", + "start_time", + "end_time" + ] + } + }, + "required": [ + "event" + ] } } } From 17d854edcd4c383b3c7c88ceea046ffd5735ac0a Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Tue, 25 Aug 2026 10:34:22 -0300 Subject: [PATCH 2/3] Add full_details field to events for API and agent context (#23773) --- app/controllers/admin/events_controller.rb | 1 + app/controllers/api/v0/events_controller.rb | 1 + app/views/admin/events/_form.html.erb | 10 ++++ app/views/admin/events/show.html.erb | 11 +++++ ...260825100500_add_full_details_to_events.rb | 5 ++ db/schema.rb | 3 +- spec/requests/admin/events_spec.rb | 40 ++++++++++++---- spec/requests/api/v0/events_spec.rb | 46 +++++++++++++++++-- spec/swagger_helper.rb | 2 + swagger/v1/api_v1.json | 12 ++++- 10 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20260825100500_add_full_details_to_events.rb diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index fa68a30dd0b85..2814828aa93cc 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -73,6 +73,7 @@ def event_params :event_name_slug, :event_variation_slug, :description, + :full_details, :primary_stream_url, :published, :elevated, diff --git a/app/controllers/api/v0/events_controller.rb b/app/controllers/api/v0/events_controller.rb index a4d7015acf41e..4e81f815f1fc9 100644 --- a/app/controllers/api/v0/events_controller.rb +++ b/app/controllers/api/v0/events_controller.rb @@ -78,6 +78,7 @@ def event_params :event_name_slug, :event_variation_slug, :description, + :full_details, :primary_stream_url, :published, :start_time, diff --git a/app/views/admin/events/_form.html.erb b/app/views/admin/events/_form.html.erb index 24d457669a3e4..c00a07b0e8e8b 100644 --- a/app/views/admin/events/_form.html.erb +++ b/app/views/admin/events/_form.html.erb @@ -39,6 +39,16 @@ <%= form.text_area :description, class: "crayons-textfield", rows: 3 %> +
+ <%= form.label :full_details, "Full Details (API Only / Agent Context)", class: "crayons-field__label" do %> + Full Details (API Only / Agent Context) +

+ Please dump all 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 not be displayed on the public event page. +

+ <% 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..." %> +
+
<%= form.label :cover_image, "Cover Image", class: "crayons-field__label" do %> Cover Image diff --git a/app/views/admin/events/show.html.erb b/app/views/admin/events/show.html.erb index f2e8fcf43eb49..229c0fc21b269 100644 --- a/app/views/admin/events/show.html.erb +++ b/app/views/admin/events/show.html.erb @@ -85,6 +85,17 @@
+
+

Full Details (API Only / Agent Context)

+ <% if @event.full_details.present? %> +
+ <%= @event.full_details %> +
+ <% else %> +

No full details dump provided yet.

+ <% end %> +
+
diff --git a/db/migrate/20260825100500_add_full_details_to_events.rb b/db/migrate/20260825100500_add_full_details_to_events.rb new file mode 100644 index 0000000000000..0b34cf303dc13 --- /dev/null +++ b/db/migrate/20260825100500_add_full_details_to_events.rb @@ -0,0 +1,5 @@ +class AddFullDetailsToEvents < ActiveRecord::Migration[8.0] + def change + add_column :events, :full_details, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 6cf384a890026..c1a7042447d75 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[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" @@ -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" diff --git a/spec/requests/admin/events_spec.rb b/spec/requests/admin/events_spec.rb index f7473d8a42023..facd39c3a9023 100644 --- a/spec/requests/admin/events_spec.rb +++ b/spec/requests/admin/events_spec.rb @@ -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 @@ -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") } @@ -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 @@ -236,6 +256,7 @@ 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) @@ -243,6 +264,7 @@ 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 diff --git a/spec/requests/api/v0/events_spec.rb b/spec/requests/api/v0/events_spec.rb index 01031919112b5..e39a5592c0ca3 100644 --- a/spec/requests/api/v0/events_spec.rb +++ b/spec/requests/api/v0/events_spec.rb @@ -81,10 +81,15 @@ 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}" + context "when requesting a published event with full_details" do + let!(:detailed_event) { create(:event, published: true, full_details: "Detailed context notes for agent") } + + it "returns the event including full_details" do + get "/api/events/#{detailed_event.id}" expect(response).to have_http_status(:success) + + json = response.parsed_body + expect(json["full_details"]).to eq("Detailed context notes for agent") end end @@ -113,6 +118,7 @@ title: "New Stream", event_name_slug: "new-stream", event_variation_slug: "v1", + full_details: "Exhaustive event details and speaker roster", start_time: 1.day.from_now, end_time: 2.days.from_now, type_of: "live_stream", @@ -132,11 +138,43 @@ expect(response).to have_http_status(:unauthorized) end - it "allows administrators to create events" do + it "allows administrators to create events with full_details" do expect do post "/api/events", params: valid_params, headers: admin_headers end.to change(Event, :count).by(1) expect(response).to have_http_status(:created) + expect(Event.last.full_details).to eq("Exhaustive event details and speaker roster") + expect(response.parsed_body["full_details"]).to eq("Exhaustive event details and speaker roster") + end + end + + describe "PATCH /api/events/:id" do + let(:event) { create(:event, published: true) } + let(:update_params) do + { + event: { + title: "Updated Stream Title", + full_details: "Updated comprehensive agenda and FAQ dump" + } + }.to_json + end + + it "blocks unauthenticated requests" do + patch "/api/events/#{event.id}", params: update_params, headers: { "content-type" => "application/json" } + expect(response).to have_http_status(:unauthorized) + end + + it "blocks basic users" do + patch "/api/events/#{event.id}", params: update_params, headers: user_headers + expect(response).to have_http_status(:unauthorized) + end + + it "allows administrators to update events with full_details" do + patch "/api/events/#{event.id}", params: update_params, headers: admin_headers + expect(response).to have_http_status(:success) + expect(event.reload.title).to eq("Updated Stream Title") + expect(event.full_details).to eq("Updated comprehensive agenda and FAQ dump") + expect(response.parsed_body["full_details"]).to eq("Updated comprehensive agenda and FAQ dump") end end end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 86013912fda8c..eb0443d68e29e 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -863,6 +863,7 @@ event_name_slug: { type: :string }, event_variation_slug: { type: :string }, description: { type: :string, nullable: true }, + full_details: { type: :string, nullable: true, description: "Full text dump of all event details, intended for agent and API consumption." }, type_of: { type: :string, enum: %w[live_stream takeover other challenge] }, start_time: { type: :string, format: "date-time" }, end_time: { type: :string, format: "date-time" }, @@ -893,6 +894,7 @@ event_name_slug: { type: :string }, event_variation_slug: { type: :string }, description: { type: :string, nullable: true }, + full_details: { type: :string, nullable: true, description: "Full text dump of all event details." }, primary_stream_url: { type: :string, nullable: true }, published: { type: :boolean, default: false }, start_time: { type: :string, format: "date-time" }, diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 38234de483109..7e8b63b01e80c 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -7947,6 +7947,11 @@ "type": "string", "nullable": true }, + "full_details": { + "type": "string", + "nullable": true, + "description": "Full text dump of all event details, intended for agent and API consumption." + }, "type_of": { "type": "string", "enum": [ @@ -8059,6 +8064,11 @@ "type": "string", "nullable": true }, + "full_details": { + "type": "string", + "nullable": true, + "description": "Full text dump of all event details." + }, "primary_stream_url": { "type": "string", "nullable": true @@ -8112,4 +8122,4 @@ } } } -} +} \ No newline at end of file From 079a49b17e27823c90649f93786cef5078818eaf Mon Sep 17 00:00:00 2001 From: Jonathan Gottfried Date: Tue, 25 Aug 2026 12:13:23 -0400 Subject: [PATCH 3/3] Customer.io: stop overriding the template sender, and stop double-sending broadcasts to the rollout cohort (#23774) * Stop overriding the Customer.io template sender DeliveryMethods::CustomerIo copied mail.from into the App API payload on every send. Customer.io treats a from in the request as an override of the sender identity configured on the transactional message, so every template send went out as ForemInstance.from_email_address regardless of what the message was set up with in Customer.io. Only send from on the body-passthrough path (no transactional_message_id), where the API needs it -- mirroring the existing guard on body. A mailer can still override it explicitly by passing from: through customerio_delivery_options. Claude-Session: https://claude.ai/code/session_012jDiQz996H7daE3dZbz1Hu * Don't double-send broadcasts to the Customer.io rollout cohort The cutover guards in Email, Emails::BatchCustomSendWorker and Admin::EmailsController all key off ForemInstance.customerio_email_cutover?, which reads the *global* :customerio_email_delivery state. The flag rolls out per actor, so while it is partially on the guards stay false and Forem keeps broadcasting to everyone -- including the enabled cohort, who are already getting the same broadcast from the Customer.io side. Skip flag-enabled recipients in the batch worker, with a per-recipient backstop in CustomMailer that also covers Emails::DripEmailWorker (it builds its sends itself and never passes through the batch worker). Test sends stay exempt so admins keep their preview during the rollout. Claude-Session: https://claude.ai/code/session_012jDiQz996H7daE3dZbz1Hu --- app/mailers/custom_mailer.rb | 20 +++++++++ app/models/email.rb | 8 +++- app/services/delivery_methods/customer_io.rb | 11 +++-- .../emails/batch_custom_send_worker.rb | 26 +++++++++++- spec/mailers/custom_mailer_spec.rb | 41 +++++++++++++++++++ .../delivery_methods/customer_io_spec.rb | 13 ++++++ .../emails/batch_custom_send_worker_spec.rb | 39 ++++++++++++++++++ 7 files changed, 152 insertions(+), 6 deletions(-) diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb index ca2a28f4892e1..a490f7ec22022 100644 --- a/app/mailers/custom_mailer.rb +++ b/app/mailers/custom_mailer.rb @@ -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) @@ -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 diff --git a/app/models/email.rb b/app/models/email.rb index a871a98c8dba9..3b54aa637aacd 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -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 @@ -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 diff --git a/app/services/delivery_methods/customer_io.rb b/app/services/delivery_methods/customer_io.rb index 1bc1eae69cace..09546acc25ef9 100644 --- a/app/services/delivery_methods/customer_io.rb +++ b/app/services/delivery_methods/customer_io.rb @@ -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 diff --git a/app/workers/emails/batch_custom_send_worker.rb b/app/workers/emails/batch_custom_send_worker.rb index af2c35e0fc9f8..25d023ffe0922 100644 --- a/app/workers/emails/batch_custom_send_worker.rb +++ b/app/workers/emails/batch_custom_send_worker.rb @@ -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]) @@ -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( @@ -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 diff --git a/spec/mailers/custom_mailer_spec.rb b/spec/mailers/custom_mailer_spec.rb index fe44010663c65..e8745a9be8114 100644 --- a/spec/mailers/custom_mailer_spec.rb +++ b/spec/mailers/custom_mailer_spec.rb @@ -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) diff --git a/spec/services/delivery_methods/customer_io_spec.rb b/spec/services/delivery_methods/customer_io_spec.rb index c59d83616248a..6d0d656051eff 100644 --- a/spec/services/delivery_methods/customer_io_spec.rb +++ b/spec/services/delivery_methods/customer_io_spec.rb @@ -39,6 +39,19 @@ def delivered_message(options = {}, delivered_mail = mail) expect(message[:message_data]).to eq("name" => "Sloan") end + # The App API treats a from in the request as an override of the sender + # identity configured on the transactional message, so sending the Rails-side + # default would make every template send from ForemInstance.from_email_address. + it "omits the from when a transactional_message_id is present so the template's sender is used" do + message = delivered_message(transactional_message_id: "dev_test_template") + expect(message).not_to have_key(:from) + end + + it "still lets a mailer override the sender explicitly through the delivery options" do + message = delivered_message(transactional_message_id: "dev_test_template", from: "custom@dev.to") + expect(message[:from]).to eq("custom@dev.to") + end + it "forwards the one-click unsubscribe headers so RFC 8058 survives the Customer.io render" do mail["List-Unsubscribe"] = "" mail["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" diff --git a/spec/workers/emails/batch_custom_send_worker_spec.rb b/spec/workers/emails/batch_custom_send_worker_spec.rb index ab16f9af7c897..a51f65a500a34 100644 --- a/spec/workers/emails/batch_custom_send_worker_spec.rb +++ b/spec/workers/emails/batch_custom_send_worker_spec.rb @@ -28,6 +28,45 @@ end end + # The cutover guard above is global, but the flag rolls out per actor: the + # enabled cohort already gets the broadcast from Customer.io, so sending it + # from here too would double-send to exactly those people. + context "when some recipients have the Customer.io delivery flag enabled" do + before do + allow(ForemInstance).to receive(:customerio_enabled?).and_return(true) + allow(FeatureFlag).to receive(:enabled_for_user?) + .with(Deliverable::CUSTOMERIO_FLAG, anything).and_return(false) + allow(FeatureFlag).to receive(:enabled_for_user?) + .with(Deliverable::CUSTOMERIO_FLAG, having_attributes(id: user.id)).and_return(true) + end + + it "skips them and still sends to everyone else" do + worker.perform(user_ids, subject_line, content, type_of, email_id) + + expect(CustomMailer).to have_received(:with).once + expect(CustomMailer).to have_received(:with).with(hash_including(user: user2)) + end + + it "still sends test emails so admins can preview during the rollout" do + worker.perform(user_ids, "[TEST] Subject", content, type_of, email_id) + + expect(CustomMailer).to have_received(:with).twice + end + end + + context "when Customer.io is not configured" do + before { allow(ForemInstance).to receive(:customerio_enabled?).and_return(false) } + + it "does not consult the flag at all" do + allow(FeatureFlag).to receive(:enabled_for_user?) + + worker.perform(user_ids, subject_line, content, type_of, email_id) + + expect(FeatureFlag).not_to have_received(:enabled_for_user?) + expect(CustomMailer).to have_received(:with).twice + end + end + context "when testing the async call" do it "queues the job with the correct arguments regardless of user ID order" do # Stub the class method perform_async