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 9f884a849285b..4e81f815f1fc9 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,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 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/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/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/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/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/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 16e043b126294..e39a5592c0ca3 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,17 +35,61 @@ 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 - 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 @@ -74,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", @@ -93,11 +138,43 @@ expect(response).to have_http_status(:unauthorized) end - it "allows administrators to create events" do - expect { + it "allows administrators to create events with full_details" do + 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) + 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/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/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/swagger_helper.rb b/spec/swagger_helper.rb index 956506f3aa24c..eb0443d68e29e 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -853,6 +853,61 @@ 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 }, + 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" }, + 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 }, + 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" }, + 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/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 diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 595b0f827d305..7e8b63b01e80c 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,7 +7925,201 @@ "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 + }, + "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": [ + "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 + }, + "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" + }, + "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" + ] } } } -} +} \ No newline at end of file