From d277edaea440715439c9f100158e7fedfddd4d3c Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:53:48 -0400 Subject: [PATCH 01/13] remove ce requested any request for ce was followed by an automatic ce record creation so no need for the boolean. --- ...uing_education_registrations_controller.rb | 2 - .../event_registrations_controller.rb | 51 +------------------ app/controllers/events/callouts_controller.rb | 1 - app/controllers/events_controller.rb | 2 +- .../event_registration_decorator.rb | 3 +- app/helpers/events_helper.rb | 1 - app/models/event_registration.rb | 6 +-- app/services/event_registration_readiness.rb | 6 +-- .../public_registration.rb | 2 - .../_continuing_education.html.erb | 27 +++------- app/views/events/onboarding/_row.html.erb | 12 ----- ...e_ce_requested_from_event_registrations.rb | 15 ++++++ db/schema.rb | 3 +- db/seeds/dev/events_management.rb | 1 - .../event_registration_decorator_spec.rb | 9 ---- ...continuing_education_registrations_spec.rb | 6 +-- spec/requests/event_registrations_spec.rb | 22 -------- spec/requests/events/registrations_spec.rb | 4 +- spec/requests/events_spec.rb | 1 - .../event_registration_readiness_spec.rb | 9 ++-- .../public_registration_spec.rb | 4 +- spec/services/event_revenue_report_spec.rb | 4 +- 22 files changed, 44 insertions(+), 147 deletions(-) create mode 100644 db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 6f32951e3..3faa96951 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -20,7 +20,6 @@ def create ActiveRecord::Base.transaction do apply_ce_params(@ce_registration) @ce_registration.save! - @event_registration.update_column(:ce_requested, true) end redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other rescue ActiveRecord::RecordInvalid @@ -55,7 +54,6 @@ def destroy registration = @ce_registration.event_registration @ce_registration.destroy! - registration.update_column(:ce_requested, false) redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index e50b05480..3dccb72f1 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -60,7 +60,6 @@ def create authorize! @event_registration if @event_registration.save - reconcile_ce_registration if @event_registration.event&.ce_eligible? respond_to do |format| format.html { redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) @@ -88,10 +87,7 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save - reconcile_ce_registration if @event_registration.event&.ce_eligible? - # Prefer the CE flash ("CE registration created/removed") when reconcile set - # one; a blocked toggle-off sets flash[:alert], which survives independently. - notice = flash[:notice].presence || "Registration was successfully updated." + notice = "Registration was successfully updated." respond_to do |format| format.turbo_stream format.html { @@ -327,56 +323,11 @@ def toggle_checklist_step(step, completed) end end - # Keep the registration's CE record in step with the `ce_requested` flag (set on - # the edit form / at intake), mirroring how a scholarship is awarded from its - # "Requested" toggle. Requested + none yet → create a stub against the chosen - # license (or the registrant's only license, else a placeholder); license/hours/ - # cost/certificate are then edited on the CE edit page. Un-requested → remove it, - # admins only, and never one that carries payments (the flag is restored and the - # admin is told to revert the payment first). Sets a flash describing what changed. - def reconcile_ce_registration - if @event_registration.ce_requested? - create_ce_registration_stub - else - remove_ce_registration - end - end - - def create_ce_registration_stub - return if @event_registration.continuing_education_registrations.exists? - - @event_registration.continuing_education_registrations.create!(professional_license: ce_license_for_create) - flash[:notice] = "CE registration created." - end - - def remove_ce_registration - registrations = @event_registration.continuing_education_registrations - return if registrations.none? || !allowed_to?(:manage?, with: EventRegistrationPolicy) - - if registrations.any? { |registration| registration.allocations.exists? } - @event_registration.update_column(:ce_requested, true) - flash[:alert] = "Can't remove CE — it has payments. Revert the payment first." - return - end - - registrations.destroy_all - flash[:notice] = "CE registration removed." - end - - # License a brand-new CE registration attaches to: the registrant's existing - # license, else an empty placeholder (number pending). Which license is used - # (and its number) is then changeable on the CE registration's edit page. - def ce_license_for_create - @event_registration.registrant.professional_licenses.first || - ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) - end - # Strong parameters def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, :scholarship_requested, - :ce_requested, :shoutout, :intends_to_pay, :expected_payment_method, diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index b652cae3c..8b3805643 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -96,7 +96,6 @@ def update_ce_license def request_ce return redirect_to(registration_ce_path(@event_registration.slug)) unless @event.ce_eligible? - @event_registration.update!(ce_requested: true) unless @event_registration.continuing_education_registrations.exists? license = ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) @event_registration.continuing_education_registrations.create!(professional_license: license) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f0f822cff..6973bc28f 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -670,7 +670,7 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals row << onboarding_scholarship_tasks_csv(registration) if include_ce ce_hours = registration.ce_hours_total - row << (registration.ce_requested? ? "Yes" : "No") + row << (registration.ce_registered? ? "Yes" : "No") row << (ce_hours.positive? ? helpers.plain_number(ce_hours) : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") row << registration.ce_license_numbers.join("; ") diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 82200f920..82374fdae 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -15,8 +15,7 @@ class EventRegistrationDecorator < ApplicationDecorator # `simulate_paid:` lets the CE callout's ?admin=true preview the post-payment state # without recording a payment. def ce_status_badge(simulate_paid: false) - return unless ce_requested? || ce_registered? - return ce_badge("Requested", "fa-solid fa-clock", :amber) unless ce_registered? + return unless ce_registered? return ce_badge("Issued", "fa-solid fa-circle-check", :green) if ce_certificate_issued? return ce_badge("License # needed", "fa-solid fa-id-card", :amber) unless ce_license_provided? return ce_badge("Pending", "fa-solid fa-hourglass-half", :blue) if ce_paid_in_full? || simulate_paid diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 2871b44ba..1c6d6fe30 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -53,7 +53,6 @@ def onboarding_columns(event) columns << { key: "funder", label: "Scholarship grant", kind: :funder, sortable: true, align: "left", toggle: "funder" } columns << { key: "scholarship_tasks_completed", label: "Scholarship tasks done", kind: :scholarship_tasks, sortable: true, align: "center", toggle: "scholarship_tasks_completed" } if event.ce_eligible? - columns << { key: "ce_requested", label: "CE requested", kind: :ce_requested, sortable: true, align: "center", toggle: "ce_requested" } columns << { key: "ce_hours", label: "CE hours", kind: :ce_hours, sortable: true, align: "center", toggle: "ce_hours" } columns << { key: "ce_amount", label: "CE amount", kind: :ce_amount, sortable: true, align: "center", toggle: "ce_amount" } columns << { key: "ce_license", label: "License #", kind: :ce_license, sortable: true, align: "center", toggle: "ce_license" } diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 2b48dc653..b65673f35 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -382,10 +382,8 @@ def cost_cents # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. - # - # `ce_requested?` is the stored intent flag (column); `ce_registered?` is whether - # a CE registration record actually exists. They align in the normal flow (the - # toggle creates the record), but the readers below key off the record. + # `ce_registered?` is whether a CE registration record actually exists — + # the readers below key off the record(s). def ce_registered? if ce_registrations_in_memory? return continuing_education_registrations.any? diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 37343012b..4b42953af 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -158,15 +158,15 @@ def scholarship_tasks_incomplete? end def ce_unpaid? - registration.ce_requested? && !ce_paid? + registration.ce_registered? && !ce_paid? end def ce_license_missing? - registration.ce_requested? && !registration.ce_license_provided? + registration.ce_registered? && !registration.ce_license_provided? end def ce_certificate_pending? - registration.ce_requested? && !ce_certificate_sent? + registration.ce_registered? && !ce_certificate_sent? end # Post-event criteria are only met by a full "attended". "incomplete_attendance" diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index a069a765c..d02876fd5 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -66,7 +66,6 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - existing.update!(ce_requested: true) if ce_credit_requested? create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? @@ -382,7 +381,6 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, - ce_requested: ce_credit_requested?, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index a6d56e56b..04f3d5627 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -1,11 +1,10 @@ -<%# ---- Continuing education — mirrors the scholarship card. "Requested" is a plain - flag saved with the form; saving it on (when none exists) creates a CE - registration stub, which is then filled in on its own edit page. Once a record - exists the toggle is gone and we show the record + an Edit link. Only rendered - for CE-eligible events. ---- %> +<%# ---- Continuing education — mirrors the scholarship card. CE registration + records are created on the dedicated CE form (license/hours/cost). This card + links to that form when no record exists, or shows the record + Edit link + once one does. Only rendered for CE-eligible events. ---- %>
- +

Continuing education

@@ -13,21 +12,7 @@
<% unless ce_registration %> - - - <%# Two ways to create the record, mirroring the scholarship card: the - Requested toggle above auto-creates a stub on save, while "Add CE - registration" opens the full new form (license/hours/cost) in a new tab - and returns here. %> + <%# Open the full new form (license/hours/cost) in a new tab and return here. %>
<%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 6177e81b7..511d8ea42 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -204,18 +204,6 @@ data: { turbo_frame: "_top" } %> - <% when :ce_requested %> - <% ce_requested = registration.ce_requested? %> - " data-sort-value="<%= ce_requested ? 1 : 0 %>"> - <%= link_to edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:opacity-80", title: "Edit CE details", data: { turbo_frame: "_top" } do %> - <% if ce_requested %> - Yes - <% else %> - No - <% end %> - <% end %> - - <% when :ce_hours %> <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> diff --git a/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb new file mode 100644 index 000000000..c4165fb9d --- /dev/null +++ b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb @@ -0,0 +1,15 @@ +class RemoveCeRequestedFromEventRegistrations < ActiveRecord::Migration[8.1] + # `ce_requested` was the intent flag mirrored by the existence of a + # ContinuingEducationRegistration record. Every flow that set it also created + # (or destroyed) the record in the same transaction, so `ce_registered?` is + # the same signal — the column is redundant and is dropped here. + def up + remove_column :event_registrations, :ce_requested, if_exists: true + end + + def down + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d0dc66e1b..32646d803 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.1].define(version: 2026_07_09_184214) do +ActiveRecord::Schema[8.1].define(version: 2026_07_11_193528) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -479,7 +479,6 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "ce_requested", default: false, null: false t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 87f3cc530..6b8560566 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -868,7 +868,6 @@ if data[:ce_credit_requested] && registration.continuing_education_registrations.none? license = ProfessionalLicense.find_or_create_for(person: data[:person], number: data[:ce_license_number]) ce_registration = registration.continuing_education_registrations.create!(professional_license: license) - registration.update_column(:ce_requested, true) # "issued" in the seed data means the CE certificate was delivered. ce_registration.mark_certificate_sent! if data[:ce_status] == "issued" end diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb index c80842049..49bb4337c 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -96,15 +96,6 @@ def pay(cer, amount) it { is_expected.to be_nil } end - context "when requested but no CE registration exists yet" do - before { registration.update!(ce_requested: true) } - - it "is an amber Requested badge" do - expect(badge.label).to eq("Requested") - expect(badge.classes).to include("amber") - end - end - context "when a CE registration sits on a placeholder license" do before do create(:continuing_education_registration, event_registration: registration, diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 581969594..fab67ec8e 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -3,7 +3,7 @@ RSpec.describe "ContinuingEducationRegistrations", type: :request do let(:admin) { create(:user, :admin) } let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } - let(:registration) { create(:event_registration, event: event, ce_requested: true) } + let(:registration) { create(:event_registration, event: event) } let(:ce_registration) do create(:continuing_education_registration, event_registration: registration, professional_license: create(:professional_license, :placeholder, person: registration.registrant)) @@ -88,7 +88,7 @@ expect(ce.cost_cents).to eq(9_000) expect(ce.professional_license).to have_attributes(kind: "LMFT", number: "555", issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) - expect(registration.reload.ce_requested).to be(true) + expect(registration.reload.ce_registered?).to be(true) end it "creates no license just from opening the new form" do @@ -156,7 +156,7 @@ ce_registration delete continuing_education_registration_path(ce_registration) expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(false) - expect(registration.reload.ce_requested).to be(false) + expect(registration.reload.ce_registered?).to be(false) end it "refuses to remove a CE registration that has payments" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 2a6b171a8..68706fb53 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -316,28 +316,6 @@ def toggle_day(field, value) expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "creates a CE registration stub when the ce_requested flag is set" do - event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 12_000) - patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - - ce_registration = existing_registration.reload.continuing_education_registrations.first - expect(ce_registration).to be_present - # Hours/cost default from the event; the license is a placeholder until set. - expect(ce_registration.hours).to eq(6) - expect(ce_registration.professional_license.number).to be_nil - end - - it "creates the CE registration against the registrant's existing license" do - event.update!(ce_hours_offered: 6) - license = create(:professional_license, person: existing_registration.registrant, number: "LIC-987") - - patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - - expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(license) - end - it "sets the shout-out flag and stores the shout-out text on the registrant" do patch event_registration_path(existing_registration), params: { event_registration: { diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index a2f04159d..22977a2fc 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -439,14 +439,14 @@ describe "POST /registration/:slug/ce/request" do let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } - let!(:registration) { create(:event_registration, event: event, registrant: user.person, ce_requested: false) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } it "opts the registrant into CE and creates the registration" do expect { post registration_ce_request_path(registration.slug) }.to change { registration.reload.continuing_education_registrations.count }.from(0).to(1) - expect(registration.ce_requested).to be(true) + expect(registration.reload.ce_registered?).to be(true) expect(response).to redirect_to(registration_ce_path(registration.slug)) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index c2dce966e..8ca3e92dd 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1215,7 +1215,6 @@ def ce_chip_text expect(response.body).to include("Mailchimp") expect(response.body).to include("CMS") expect(response.body).to include("Portal invite") - expect(response.body).to include("CE requested") expect(response.body).to include("License #") expect(response.body).to include("Event attendance") expect(response.body).to include("Onboard") diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 2e778dab1..213ea9515 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -90,7 +90,6 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) context "continuing education" do it "flags CE as unpaid when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_requested: true) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) @@ -99,7 +98,6 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "flags a missing CE license number when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_requested: true) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) @@ -156,7 +154,9 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "flags the CE certificate as unsent when CE credit was requested" do - registration.update!(status: "attended", ce_requested: true) + registration.update!(status: "attended") + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.completion_issues).to include("CE certificate not sent") end @@ -275,7 +275,8 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "names both certificates when CE credit was requested" do - registration.update!(ce_requested: true) + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.certificate_due_reason).to eq("Reg + CE") end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 30b1db22c..29c0eeab7 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -574,13 +574,13 @@ def register_with_ce(answer, license: nil) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_requested).to be(true) + expect(existing.reload.ce_registered?).to be(true) expect(existing.continuing_education_registrations.count).to eq(1) end it "does not duplicate a CE registration when the existing one already has one" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_requested: true) + existing = create(:event_registration, event: event, registrant: person) license = create(:professional_license, :placeholder, person: person) create(:continuing_education_registration, event_registration: existing, professional_license: license) diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 0de79b000..24e7f50b1 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -10,7 +10,7 @@ let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } let!(:reg2) do - create(:event_registration, event: event, registrant: person2, status: "registered", ce_requested: true) + create(:event_registration, event: event, registrant: person2, status: "registered") end before do @@ -18,7 +18,7 @@ create(:continuing_education_registration, event_registration: reg2, cost_cents: 7_500) # A cancelled registration whose money/CE must be ignored everywhere. - cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", ce_requested: true) + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") create(:continuing_education_registration, event_registration: cancelled, cost_cents: 12_000) create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), allocatable: cancelled, amount: 5_000) From b5148f3829c8c7676756e62bc17450ce551c583a Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:36:58 -0400 Subject: [PATCH 02/13] add ce reg form role --- app/controllers/events/callouts_controller.rb | 8 +- .../events/public_registrations_controller.rb | 36 +++++++- app/controllers/events_controller.rb | 2 + app/models/event.rb | 27 ++++++ app/models/event_form.rb | 3 +- .../public_registration.rb | 55 +++++++++++-- app/services/form_builder_service.rb | 31 ++++++- app/views/events/_form.html.erb | 17 ++++ .../events/public_registrations/new.html.erb | 22 +++++ .../events/public_registrations/show.html.erb | 51 ++++++++++++ db/seeds/dev/events_management.rb | 82 ++++--------------- spec/factories/event_forms.rb | 4 + .../public_registration_spec.rb | 61 +++++++------- ...ublic_registration_form_submission_spec.rb | 36 ++++---- 14 files changed, 308 insertions(+), 127 deletions(-) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 8b3805643..ef6d53339 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -160,13 +160,11 @@ def set_event @event = @event_registration.event end - # Keep the registrant's form submission in step with a license number entered - # on the callout, so the registration record shows the same value. A no-op - # when the form, field, or submission isn't present. + # Update form submission if ce record is updated via callout def record_ce_license_answer(number) - form = @event.registration_form + form = @event.continuing_education_form field = form&.form_fields&.find_by(field_identifier: "ce_license_number") - submission = form&.form_submissions&.find_by(person: @event_registration.registrant) + submission = form&.form_submissions&.find_by(person: @event_registration.registrant, role: "continuing_education") return unless field && submission answer = submission.form_answers.find_or_initialize_by(form_field: field) diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 0645472d0..a4ea0e597 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -21,6 +21,7 @@ def new @form_fields = visible_form_fields @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form @event = @event.decorate end @@ -35,17 +36,23 @@ def create @registration_form = registration_form @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form all_params = params.dig(:public_registration, :form_fields)&.to_unsafe_h || {} - registration_params, scholarship_params = split_form_params(all_params) + registration_params, scholarship_params, continuing_education_params = split_form_params(all_params) @field_errors = validate_required_fields(registration_params) if @scholarship_form scholarship_fields = @scholarship_form.form_fields.where.not(answer_type: :group_header).to_a @field_errors = @field_errors.merge(FormAnswerValidator.call(scholarship_fields, scholarship_params)) end + if @continuing_education_form + ce_fields = @continuing_education_form.form_fields.where.not(answer_type: :group_header).to_a + @field_errors = @field_errors.merge(FormAnswerValidator.call(ce_fields, continuing_education_params)) + end if @field_errors.any? @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." render :new, status: :unprocessable_content @@ -61,7 +68,9 @@ def create scholarship_requested: @scholarship, person: current_user&.person, scholarship_form: @scholarship_form, - scholarship_params: scholarship_params + scholarship_params: scholarship_params, + continuing_education_form: @continuing_education_form, + continuing_education_params: continuing_education_params ) if result.success? @@ -76,6 +85,7 @@ def create end else @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:error] = result.errors.join(", ") flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." @@ -120,6 +130,7 @@ def show @responses = @form_submission.form_answers.index_by(&:form_field_id) load_scholarship_submission(person) + load_continuing_education_submission(person) @event = @event.decorate end @@ -190,6 +201,19 @@ def load_scholarship_submission(person) @scholarship_responses = application.answers_by_field_id end + def load_continuing_education_submission(person) + ce_form = @event.continuing_education_form + return unless ce_form + + submission = ce_form.form_submissions.find_by(person: person, event: @event, role: "continuing_education") + return unless submission + + @continuing_education_submission = submission + @continuing_education_form = ce_form + @continuing_education_fields = ce_form.form_fields.reorder(position: :asc) + @continuing_education_responses = submission.form_answers.index_by(&:form_field_id) + end + def scholarship_mode? params[:scholarship_requested] == "true" end @@ -204,7 +228,13 @@ def split_form_params(all_params) scholarship = all_params.slice(*scholarship_field_ids) end - [ registration, scholarship ] + continuing_education = {} + if @continuing_education_form + ce_field_ids = @continuing_education_form.form_fields.pluck(:id).map(&:to_s) + continuing_education = all_params.slice(*ce_field_ids) + end + + [ registration, scholarship, continuing_education ] end def visible_form_fields diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 6973bc28f..e8eaf11cb 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -700,6 +700,7 @@ def assign_event_forms(event) assign_event_form(event, :registration, params.dig(:event, :registration_form_id)) assign_event_form(event, :scholarship, params.dig(:event, :scholarship_form_id)) assign_event_form(event, :bulk_payment, params.dig(:event, :bulk_payment_form_id)) + assign_event_form(event, :continuing_education, params.dig(:event, :continuing_education_form_id)) end def assign_event_form(event, role, form_id) @@ -719,6 +720,7 @@ def set_form_variables @registration_forms = Form.standalone.where(role: "registration").order(:name) @scholarship_forms = Form.standalone.where(role: "scholarship").order(:name) @bulk_payment_forms = Form.standalone.where(role: "bulk_payment").order(:name) + @continuing_education_forms = Form.standalone.where(role: "continuing_education").order(:name) @categories_grouped = Category .includes(:category_type) diff --git a/app/models/event.rb b/app/models/event.rb index 604acd7a5..1fcd4ec7e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -51,6 +51,7 @@ class Event < ApplicationRecord # Callbacks after_commit :build_public_registration_form, if: :public_registration_just_enabled? + after_commit :manage_continuing_education_form, if: :ce_config_changed? before_validation :merge_date_time_fields # Validations @@ -151,6 +152,10 @@ def bulk_payment_form forms.find_by(event_forms: { role: "bulk_payment" }) end + def continuing_education_form + forms.find_by(event_forms: { role: "continuing_education" }) + end + def active_registration_for(person) return nil unless person event_registrations.active.find_by(registrant_id: person.id) @@ -355,4 +360,26 @@ def build_public_registration_form event_forms.create!(form: form, role: "registration") end + + def ce_config_changed? + saved_change_to_ce_hours_offered? || saved_change_to_ce_hours_cost_cents? + end + + # When several CE forms exist the admin's dropdown picks one; this callback only + # auto-attaches when there's exactly one (or removes when eligibility is lost). + def manage_continuing_education_form + ce_eligible = ce_hours_offered.to_i > 0 && ce_hours_cost_cents.to_i > 0 + + unless ce_eligible + event_forms.continuing_education.destroy_all + return + end + + return if continuing_education_form.present? + + ce_forms = Form.standalone.where(role: "continuing_education") + return unless ce_forms.size == 1 + + event_forms.create!(form: ce_forms.first, role: "continuing_education") + end end diff --git a/app/models/event_form.rb b/app/models/event_form.rb index 4f2f6bdda..5ffb4c88b 100644 --- a/app/models/event_form.rb +++ b/app/models/event_form.rb @@ -2,7 +2,7 @@ class EventForm < ApplicationRecord belongs_to :event belongs_to :form - ROLES = %w[registration scholarship bulk_payment].freeze + ROLES = %w[registration scholarship bulk_payment continuing_education].freeze validates :role, presence: true, inclusion: { in: ROLES } validates :form_id, uniqueness: { scope: [ :event_id, :role ] } @@ -10,4 +10,5 @@ class EventForm < ApplicationRecord scope :registration, -> { where(role: "registration") } scope :scholarship, -> { where(role: "scholarship") } scope :bulk_payment, -> { where(role: "bulk_payment") } + scope :continuing_education, -> { where(role: "continuing_education") } end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index d02876fd5..1719e172d 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -28,13 +28,16 @@ class PublicRegistration ORGANIZATION_POSITION_IDENTIFIER = "agency_position".freeze def self.call(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) new(event:, registration_form:, form_params:, scholarship_requested:, person:, - scholarship_form:, scholarship_params:).call + scholarship_form:, scholarship_params:, + continuing_education_form:, continuing_education_params:).call end def initialize(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) @event = event @registration_form = registration_form @form_params = form_params @@ -42,6 +45,8 @@ def initialize(event:, registration_form:, form_params:, scholarship_requested: @person = person @scholarship_form = scholarship_form @scholarship_params = scholarship_params || {} + @continuing_education_form = continuing_education_form + @continuing_education_params = continuing_education_params || {} @errors = [] end @@ -78,6 +83,7 @@ def call connect_organization(existing, organization) submission = update_form_submission(person) save_scholarship_submission(person) + save_continuing_education_submission(person) return Result.new(success?: true, event_registration: existing, form_submission: submission, errors: []) end @@ -86,6 +92,7 @@ def call connect_organization(event_registration, organization) submission = create_form_submission(person) save_scholarship_submission(person) + save_continuing_education_submission(person) send_notifications(event_registration) @@ -399,13 +406,23 @@ def create_ce_registration(event_registration, person) event_registration.continuing_education_registrations.create!(professional_license: license) end - # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") + return false unless @continuing_education_form + + ce_field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") end def ce_license_number - field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence + end + + def ce_field_value(key) + field = @continuing_education_form.form_fields.find_by(field_identifier: key) + return nil unless field + + @continuing_education_params[field.id.to_s] end # The "Additional forms" question is a multi-select, so its submitted value is @@ -481,6 +498,32 @@ def save_scholarship_submission(person) end end + def save_continuing_education_submission(person) + return unless @continuing_education_form && @continuing_education_params.present? + return unless ce_credit_requested? + + submission = FormSubmission.find_or_create_by!( + person: person, form: @continuing_education_form, role: "continuing_education", event: @event + ) do |record| + record.event = @event + end + + @continuing_education_params.each do |field_id, raw_value| + field = @continuing_education_form.form_fields.find_by(id: field_id) + next unless field + next if field.group_header? + + text = if raw_value.is_a?(Array) + raw_value.reject(&:blank?).join(", ") + else + raw_value.to_s + end + + record = submission.form_answers.find_or_initialize_by(form_field: field) + record.update!(submitted_answer: text, question_name_when_answered: field.name) + end + end + def send_notifications(event_registration) registrant_email = event_registration.registrant.preferred_email diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 5144d3dfa..dd5e727c1 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -11,6 +11,7 @@ class FormBuilderService person_background: { label: "Person background", method: :build_person_background_fields }, professional_info: { label: "Professional info", method: :build_professional_info_fields }, marketing: { label: "Marketing", method: :build_marketing_fields }, + continuing_education: { label: "Continuing education", method: :build_continuing_education_fields }, scholarship: { label: "Scholarship", method: :build_scholarship_fields }, payment: { label: "Payment", method: :build_payment_fields }, consent: { label: "Consent", method: :build_consent_fields }, @@ -51,6 +52,7 @@ def call person_background: %w[racial_ethnic_identity], professional_info: %w[primary_sector_single additional_sectors primary_age_group additional_age_group], marketing: %w[referral_source training_motivation interested_in_more], + continuing_education: %w[ce_credit_interest ce_license_number], scholarship: %w[scholarship_eligibility scholarship_contribution impact_description implementation_plan additional_comments], payment: %w[payment_method], consent: %w[communication_consent], @@ -65,6 +67,7 @@ def call person_background: [ "Background Information" ], professional_info: [ "Professional Information" ], marketing: [ "Marketing" ], + continuing_education: [ "Continuing education" ], scholarship: [ "Scholarship Application", "Your goals", "Anything else?" ], payment: [ "Payment Information" ], consent: [ "Consent" ], @@ -92,6 +95,10 @@ def call "What motivated you to sign up for AWBW's Facilitator Training?", "Are you interested in learning more about upcoming trainings or resources?" ], + continuing_education: [ + "Do you seek Continuing Education (CE) hours for this training?", + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?" + ], scholarship: [ "I and/or my organization cannot afford the full training cost and need a scholarship to attend.", "How much are you (and/or your organization) able to pay for this training?", @@ -123,6 +130,7 @@ def self.section_field_names(key) person_background: %w[background], professional_info: %w[professional], marketing: %w[marketing], + continuing_education: %w[continuing_education], scholarship: %w[scholarship], payment: %w[payment], consent: %w[consent], @@ -138,7 +146,8 @@ def self.applicable_section_keys(role) case role when "scholarship" then key == :scholarship when "bulk_payment" then key == :bulk_payment - else key != :scholarship && key != :bulk_payment + when "continuing_education" then key == :continuing_education + else key != :scholarship && key != :bulk_payment && key != :continuing_education end end end @@ -291,6 +300,7 @@ def self.reorder_to_page_order!(form, section_order) "background" => :logged_out_only, "professional" => :answers_on_file, "marketing" => :answers_on_file, + "continuing_education" => :always_ask, "payment" => :always_ask, "scholarship" => :always_ask, "consent" => :answers_on_file, @@ -496,6 +506,25 @@ def build_marketing_fields(form, position) position end + def build_continuing_education_fields(form, position) + position = add_header(form, position, "Continuing education", group: "continuing_education") + + position = add_field(form, position, + "Do you seek Continuing Education (CE) hours for this training?", + :single_select_radio, + key: "ce_credit_interest", group: "continuing_education", required: false, + options: %w[Yes No]) + position = add_field(form, position, + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", + :free_form_input_one_line, + key: "ce_license_number", group: "continuing_education", required: false, + subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ + "and AWBW cannot guarantee your specific state board will accept them. " \ + "Participants are responsible for confirming whether the hours meet the requirements " \ + "for their specific license and state.") + position + end + def build_scholarship_fields(form, position) position = add_header(form, position, "Scholarship Application", group: "scholarship") diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 8d981534f..657443aa1 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -461,6 +461,23 @@ <% end %>
<% end %> + <% if @continuing_education_forms&.size.to_i > 1 %> +
+ + <% default_ce_id = if resubmitted + params.dig(:event, :continuing_education_form_id).presence&.to_i + elsif @event.continuing_education_form + @event.continuing_education_form.id + end %> + +

Select a continuing education form for registrants to request CE hours.

+
+ <% end %>
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index 586cc518c..d16835d35 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -156,6 +156,28 @@ <% end %> + <% if @continuing_education_form && @continuing_education_form.form_fields.any? %> +
+

+ <%= @continuing_education_form.display_name %> +

+ <%= render "forms/header", form: @continuing_education_form, event: @event %> +
+ <% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %> + <% if field.group_header? %> + <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> + <%= render "events/public_registrations/group_header", field: field %> + <% else %> + <% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) %> +
+ <%= render "events/public_registrations/form_field", field: field, value: submitted_value %> +
+ <% end %> + <% end %> +
+
+ <% end %> +
<%= button_tag type: "submit", class: "w-full inline-flex items-center justify-center gap-2.5 rounded-xl bg-blue-900 px-6 py-3.5 text-white font-semibold text-lg shadow-lg shadow-blue-900/20 transition hover:bg-blue-800 hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer" do %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index bc6d0e2d5..73646434d 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -132,4 +132,55 @@
<% end %> + + <%# ---- CONTINUING EDUCATION CARD — only when the registrant filled out the + separate continuing_education form (its own role: "continuing_education" + submission). ---- %> + <% if @continuing_education_submission %> +
+ + <%# Card Header %> +
+
+ +
+

Continuing education

+

<%= @continuing_education_form.display_name %>

+
+
+
+
+ + <%# Card Body %> +
+ <%= render "forms/header", form: @continuing_education_form, event: @event %> + +
+ <% @continuing_education_fields.each do |field| %> + <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> + + <% if field.group_header? %> +
+

+ <%= form_label_html(field.name) %> +

+
+ <% else %> + <% response = @continuing_education_responses[field.id] %> +
+
<%= form_label_html(display_question_label(field, response)) %>
+
<%= display_response_text(field, response) %>
+
+ <% end %> + <% end %> +
+ +
+

+ Submitted on <%= @continuing_education_submission.created_at.strftime("%B %d, %Y at %l:%M %P") %> +

+
+
+
+ <% end %> diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 6b8560566..3489e2d4a 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -37,11 +37,20 @@ ).call end +unless Form.standalone.find_by(role: "continuing_education") + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call +end + puts "Creating Events with shared forms…" admin_user = User.find_by(email: "umberto.user@example.com") registration_form = Form.standalone.find_by!(role: "registration") scholarship_form = Form.standalone.find_by!(role: "scholarship") bulk_payment_form = Form.standalone.find_by!(role: "bulk_payment") +continuing_education_form = Form.standalone.find_by!(role: "continuing_education") # Seed an example header (admin-authored HTML intro shown under the form title on # the public registration page) onto the base registration form, mirroring the @@ -155,71 +164,6 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose "Yes" creates the -# registration's ContinuingEducationRegistration (see -# EventRegistrationServices::PublicRegistration). Seeded straight onto the form -# with its own section so the form builder's add/remove-section logic leaves it -# alone, and carrying the well-known field_identifier the service keys off. A -# follow-on license-number question mirrors the real form's CE block. -ce_identifier = EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER -if registration_form.form_fields.where(field_identifier: ce_identifier).none? - next_position = (registration_form.form_fields.maximum(:position) || 0) + 1 - registration_form.form_fields.create!( - name: "Continuing education", - answer_type: :group_header, - status: :active, - position: next_position, - required: false, - section: "continuing_education", - visibility: :always_ask - ) - ce_field = registration_form.form_fields.create!( - name: "Do you seek Continuing Education (CE) hours for this training?", - answer_type: :single_select_radio, - status: :active, - position: next_position + 1, - required: false, - field_identifier: ce_identifier, - section: "continuing_education", - visibility: :always_ask, - width: :full - ) - %w[Yes No].each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - ce_field.form_field_answer_options.create!(answer_option: ao) - end - registration_form.form_fields.create!( - name: "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", - answer_type: :free_form_input_one_line, - status: :active, - position: next_position + 2, - required: false, - field_identifier: "ce_license_number", - section: "continuing_education", - visibility: :always_ask, - width: :full, - subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ - "and AWBW cannot guarantee your specific state board will accept them. Participants are responsible " \ - "for confirming whether the hours meet the requirements for their specific license and state." - ) -end - -# Bring an already-seeded CE block up to the current wording (these only run when -# the field exists from an earlier seed, since the create above skips them then). -# Idempotent — each clause only matches the prior default copy. -registration_form.form_fields - .where(field_identifier: ce_identifier, name: "Do you plan to use Continuing Education (CE) hours for this course?") - .update_all(name: "Do you seek Continuing Education (CE) hours for this training?") -registration_form.form_fields - .where(field_identifier: ce_identifier, - subtitle: "CE hours are available for select trainings. Let us know and our team will follow up with details.") - .update_all(subtitle: nil) -registration_form.form_fields - .where(field_identifier: "ce_license_number", subtitle: [ nil, "" ]) - .update_all(subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ - "and AWBW cannot guarantee your specific state board will accept them. Participants are responsible " \ - "for confirming whether the hours meet the requirements for their specific license and state.") - # The "Additional forms" question: a multi-select whose checked options drive the # resulting registration's invoice_requested / w9_requested flags (see # EventRegistrationServices::PublicRegistration). The digital ticket reads those @@ -267,7 +211,7 @@ # the order the real form uses. Idempotent: re-running lands on the same order. registration_section_order = %w[ person_identifier person_contact_info professional background marketing - continuing_education payment additional_forms consent + payment additional_forms consent ] registration_section_rank = registration_section_order.each_with_index.to_h registration_form.form_fields.reorder(:position).to_a.each_with_index @@ -348,6 +292,12 @@ ef.form = bulk_payment_form end end + + if event.ce_hours_offered.to_i > 0 + EventForm.find_or_create_by!(event: event, role: "continuing_education") do |ef| + ef.form = continuing_education_form + end + end end # The flagship training runs on Zoom — drive the platform from event settings so diff --git a/spec/factories/event_forms.rb b/spec/factories/event_forms.rb index 3f17a9fb1..277bcec17 100644 --- a/spec/factories/event_forms.rb +++ b/spec/factories/event_forms.rb @@ -11,5 +11,9 @@ trait :scholarship do role { "scholarship" } end + + trait :continuing_education do + role { "continuing_education" } + end end end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 29c0eeab7..b5cf5164e 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -513,43 +513,28 @@ def register_with_agency_type(value) end end - describe "CE credit interest (magic question)" do - let!(:ce_field) do - field = form.form_fields.create!( - name: "Might you be seeking continuing education (CE) hours for attending this training?", - answer_type: :single_select_radio, - status: :active, - position: (form.form_fields.maximum(:position) || 0) + 1, - required: false, - field_identifier: described_class::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", - visibility: :always_ask - ) - %w[Yes No].each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - field + describe "CE credit interest (continuing_education form)" do + let!(:ce_form) do + f = FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + event.event_forms.create!(form: f, role: "continuing_education") + f end - let!(:ce_license_field) do - form.form_fields.create!( - name: "License number", - answer_type: :free_form_input_one_line, - status: :active, - position: (form.form_fields.maximum(:position) || 0) + 1, - required: false, - field_identifier: described_class::CE_LICENSE_NUMBER_IDENTIFIER, - section: "continuing_education", - visibility: :always_ask - ) + def ce_field_id(key) + ce_form.form_fields.find_by!(field_identifier: key).id.to_s end def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") - params = params.merge(ce_field.id.to_s => answer) unless answer.nil? - params = params.merge(ce_license_field.id.to_s => license) if license - described_class.call(event: event, registration_form: form, form_params: params) + ce_params = {} + ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil? + ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license + described_class.call(event: event, registration_form: form, form_params: params, + continuing_education_form: ce_form, continuing_education_params: ce_params) end it "creates a CE registration when answered Yes" do @@ -606,6 +591,20 @@ def register_with_ce(answer, license: nil) result = register_with_ce("Yes") expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) end + + it "persists the CE answers as a continuing_education submission when answered Yes" do + register_with_ce("Yes", license: "LMFT 555") + submission = FormSubmission.find_by(role: "continuing_education", event: event) + expect(submission).to be_present + answers = submission.answers_by_identifier + expect(answers[described_class::CE_CREDIT_INTEREST_IDENTIFIER]).to eq("Yes") + expect(answers[described_class::CE_LICENSE_NUMBER_IDENTIFIER]).to eq("LMFT 555") + end + + it "does not persist a continuing_education submission when answered No" do + register_with_ce("No") + expect(FormSubmission.find_by(role: "continuing_education", event: event)).to be_nil + end end describe "Additional forms (multi-select magic question)" do diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index 74102fcb1..85b66cb54 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -17,6 +17,7 @@ end let(:registration_form) { build_registration_form } + let(:continuing_education_form) { build_continuing_education_form } let(:scholarship_form) do FormBuilderService.new(name: "Scholarship Application", sections: %i[scholarship], role: "scholarship").call end @@ -51,6 +52,7 @@ before do driven_by(:rack_test) EventForm.create!(event: event, form: registration_form, role: "registration") + EventForm.create!(event: event, form: continuing_education_form, role: "continuing_education") EventForm.create!(event: event, form: scholarship_form, role: "scholarship") EventForm.create!(event: event, form: bulk_payment_form, role: "bulk_payment") end @@ -61,7 +63,7 @@ fill_full_registration - choose_pr_radio reg_field("ce_credit_interest"), "Yes" + choose_pr_radio ce_field("ce_credit_interest"), "Yes" choose_pr_radio reg_field("payment_method"), "Check" check_pr_box reg_field("additional_forms"), "W-9" @@ -106,11 +108,15 @@ "racial_ethnic_identity" => "Multi-racial", "referral_source" => "Online Search", "training_motivation" => "Address staff burnout through art", - "ce_credit_interest" => "Yes", "payment_method" => "Check", "additional_forms" => "W-9", "communication_consent" => "Yes" ) + expect(answers).not_to have_key("ce_credit_interest") + + ce_submission = continuing_education_form.form_submissions.find_by!(person: person) + ce_answers = answers_by_identifier(ce_submission) + expect(ce_answers).to include("ce_credit_interest" => "Yes") # The service never stores the confirm_email answer (it only checks it matches). expect(answers).not_to have_key("confirm_email") @@ -277,8 +283,8 @@ # Builds the registration form the way db/seeds/dev/events_management.rb does: # the full set of sections, minus the generic "interested in more?" question, - # plus the magic CE-interest and "Additional forms" questions whose answers - # drive the resulting registration's flags. + # plus the "Additional forms" question whose answers drive the resulting + # registration's flags. def build_registration_form form = FormBuilderService.new( name: "Training Registration Form", @@ -288,16 +294,6 @@ def build_registration_form form.form_fields.where(field_identifier: "interested_in_more").destroy_all position = form.form_fields.maximum(:position).to_i - ce = form.form_fields.create!( - name: "Do you plan to use Continuing Education (CE) hours for this course?", - answer_type: :single_select_radio, status: :active, position: position += 1, required: false, - field_identifier: EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", visibility: :always_ask - ) - %w[Yes No].each do |name| - ce.form_field_answer_options.create!(answer_option: AnswerOption.find_or_create_by!(name: name)) - end - additional_forms = form.form_fields.create!( name: "Do you need either of the following?", answer_type: :multi_select_checkbox, status: :active, position: position += 1, required: false, @@ -313,12 +309,24 @@ def build_registration_form form end + def build_continuing_education_form + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + end + # ---- Field lookup ---- def reg_field(identifier) registration_form.form_fields.find_by!(field_identifier: identifier) end + def ce_field(identifier) + continuing_education_form.form_fields.find_by!(field_identifier: identifier) + end + def schol_field(identifier) scholarship_form.form_fields.find_by!(field_identifier: identifier) end From 27b9321158f5ac053f7f4beafb7c28b95fe9ac76 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:45:39 -0400 Subject: [PATCH 03/13] do not guard on 1 --- app/models/event.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 1fcd4ec7e..1f18cb529 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -365,8 +365,6 @@ def ce_config_changed? saved_change_to_ce_hours_offered? || saved_change_to_ce_hours_cost_cents? end - # When several CE forms exist the admin's dropdown picks one; this callback only - # auto-attaches when there's exactly one (or removes when eligibility is lost). def manage_continuing_education_form ce_eligible = ce_hours_offered.to_i > 0 && ce_hours_cost_cents.to_i > 0 @@ -377,9 +375,9 @@ def manage_continuing_education_form return if continuing_education_form.present? - ce_forms = Form.standalone.where(role: "continuing_education") - return unless ce_forms.size == 1 + ce_form = Form.standalone.where(role: "continuing_education").first + return unless ce_form - event_forms.create!(form: ce_forms.first, role: "continuing_education") + event_forms.create!(form: ce_form, role: "continuing_education") end end From 77203c0c9fe519e6f006f4c26ced717f0f5c1233 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:06:24 -0400 Subject: [PATCH 04/13] add ce form role to edit/new --- .../javascript/controllers/section_filter_controller.js | 4 +++- app/views/forms/edit.html.erb | 2 +- app/views/forms/new.html.erb | 8 +++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/frontend/javascript/controllers/section_filter_controller.js b/app/frontend/javascript/controllers/section_filter_controller.js index 105515136..fb3c441e6 100644 --- a/app/frontend/javascript/controllers/section_filter_controller.js +++ b/app/frontend/javascript/controllers/section_filter_controller.js @@ -15,8 +15,10 @@ export default class extends Controller { el.classList.toggle("hidden", sectionRole !== "scholarship") } else if (role === "bulk_payment") { el.classList.toggle("hidden", sectionRole !== "bulk_payment") + } else if (role === "continuing_education") { + el.classList.toggle("hidden", sectionRole !== "continuing_education") } else { - el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment") + el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment" || sectionRole === "continuing_education") } }) } diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index 107ea464b..564dd204d 100644 --- a/app/views/forms/edit.html.erb +++ b/app/views/forms/edit.html.erb @@ -79,7 +79,7 @@
<%= f.select :role, - [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"]], + [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"], ["Continuing Education", "continuing_education"]], { selected: @form.role || "" }, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb index 2cee1b8fd..f24898f77 100644 --- a/app/views/forms/new.html.erb +++ b/app/views/forms/new.html.erb @@ -43,6 +43,7 @@ + @@ -57,7 +58,12 @@
<% FormBuilderService::SECTIONS.each do |key, section| %> - <% section_role = key == :scholarship ? "scholarship" : key == :bulk_payment ? "bulk_payment" : "other" %> + <% section_role = case key + when :scholarship then "scholarship" + when :bulk_payment then "bulk_payment" + when :continuing_education then "continuing_education" + else "other" + end %> <% field_names = FormBuilderService.section_field_names(key) %>
From 986c6f215f6ee53cee2de17fbad248cb69c08ab7 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:53:29 -0400 Subject: [PATCH 05/13] remove check for ce offered, just use form --- app/models/event.rb | 20 -------------------- db/seeds/dev/events_management.rb | 7 ------- 2 files changed, 27 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 1f18cb529..6204660b4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -51,7 +51,6 @@ class Event < ApplicationRecord # Callbacks after_commit :build_public_registration_form, if: :public_registration_just_enabled? - after_commit :manage_continuing_education_form, if: :ce_config_changed? before_validation :merge_date_time_fields # Validations @@ -361,23 +360,4 @@ def build_public_registration_form event_forms.create!(form: form, role: "registration") end - def ce_config_changed? - saved_change_to_ce_hours_offered? || saved_change_to_ce_hours_cost_cents? - end - - def manage_continuing_education_form - ce_eligible = ce_hours_offered.to_i > 0 && ce_hours_cost_cents.to_i > 0 - - unless ce_eligible - event_forms.continuing_education.destroy_all - return - end - - return if continuing_education_form.present? - - ce_form = Form.standalone.where(role: "continuing_education").first - return unless ce_form - - event_forms.create!(form: ce_form, role: "continuing_education") - end end diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 3489e2d4a..c2a6e749b 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -293,13 +293,6 @@ end end - if event.ce_hours_offered.to_i > 0 - EventForm.find_or_create_by!(event: event, role: "continuing_education") do |ef| - ef.form = continuing_education_form - end - end -end - # The flagship training runs on Zoom — drive the platform from event settings so # it shows as a badge in the public registration header (rather than living in the # shared form header). It also turns on the structured "at a glance" details panel From 13f817e5f0156e7f9f374df1b6bdcb655714bfd0 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:56:35 -0400 Subject: [PATCH 06/13] add basic ce form selection --- app/views/events/_form.html.erb | 605 +++++++++++++++++--------------- 1 file changed, 331 insertions(+), 274 deletions(-) diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 657443aa1..5c34ef6b9 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -8,6 +8,7 @@
+
@@ -22,8 +23,10 @@ "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> + <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %>
+ <%= f.input :pre_title, label: "Pre-title", hint: "Displays above the title", @@ -38,14 +41,14 @@ <% if allowed_to?(:manage?, @event) %>
<%= render "shared/visibility_info", flags: %i[published featured publicly_visible publicly_featured public_registration_enabled] %> +
<%= visibility_flag_input f, :published %> <%= visibility_flag_input f, :featured %> <%= visibility_flag_input f, :publicly_visible %> <%= visibility_flag_input f, :publicly_featured %> -
- <%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %> -
+ +
<%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %>
<% end %> @@ -58,97 +61,83 @@ id="event_details_button" class="flex items-center justify-between cursor-pointer w-full bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-t-md" data-action="dropdown#toggle" - data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]'> + data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]' + > Event details
- <%# LEFT: Date/time, cost + pre-date text, videoconference + location %> -
-
-
-

Start

-
- - <%= f.text_field :start_date_date, + <%# LEFT: Date/time, cost + pre-date text, videoconference + location %> +
+
+
+

Start

+ +
<%= f.text_field :start_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
-
- - <%= f.text_field :start_date_time, + required: true %>
+
<%= f.text_field :start_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
- <% if f.object.errors[:start_date].any? %> -

Start date - <%= f.object.errors[:start_date].join(", ") %>

- <% end %> -
+ required: true %>
+ + <% if f.object.errors[:start_date].any? %> +

Start date <%= f.object.errors[:start_date].join(", ") %>

+ <% end %> +
-
-

End

-
- - <%= f.text_field :end_date_date, +
+

End

+ +
<%= f.text_field :end_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
-
- - <%= f.text_field :end_date_time, + required: true %>
+
<%= f.text_field :end_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
- <% if f.object.errors[:end_date].any? %> -

End date - <%= f.object.errors[:end_date].join(", ") %>

- <% end %> -
+ required: true %>
+ + <% if f.object.errors[:end_date].any? %> +

End date <%= f.object.errors[:end_date].join(", ") %>

+ <% end %> +
-
-

Registration closed

- <% rc_default = @event.registration_close_date || event_registration_close_default(@event) %> -
- - <%= f.text_field :registration_close_date_date, +
+

Registration closed

+ <% rc_default = @event.registration_close_date || event_registration_close_default(@event) %> + +
+ + + <%= f.text_field :registration_close_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", value: rc_default.strftime("%Y-%m-%d") %> -
-
- - <%= f.text_field :registration_close_date_time, +
+ +
<%= f.text_field :registration_close_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - value: rc_default.strftime("%H:%M") %> -
- <% if f.object.errors[:registration_close_date].any? %> -

- Registration close date - <%= f.object.errors[:registration_close_date].join(", ") %> -

- <% end %> -
-
+ value: rc_default.strftime("%H:%M") %>
-
-
-
- <%= f.label :cost, "Event Cost", class: "block font-medium" %> - Enter $0 if the event is free + <% if f.object.errors[:registration_close_date].any? %> +

Registration close date <%= f.object.errors[:registration_close_date].join(", ") %>

+ <% end %> +
-
- $ +
+
+
<%= f.label :cost, "Event Cost", class: "block font-medium" %>Enter $0 if the event is free
+ +
+ $ - <%= f.text_field :cost, + <%= f.text_field :cost, type: "number", step: "0.01", min: "0", @@ -157,26 +146,23 @@ "if(this.value.includes('.')) { var parts = this.value.split('.'); if(parts[1] && parts[1].length > 2) { this.value = parts[0] + '.' + parts[1].substring(0, 2); } }", class: "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %> -
+
- <% if f.object.errors[:cost].any? %> -

Cost - <%= f.object.errors[:cost].join(", ") %>

- <% end %> -
+ <% if f.object.errors[:cost].any? %> +

Cost <%= f.object.errors[:cost].join(", ") %>

+ <% end %> +
-
- <%= f.input :pre_date_text, +
<%= f.input :pre_date_text, label: "Pre-date text", input_html: { class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", placeholder: "e.g. Upcoming Workshop" - } %> -
-
+ } %>
+
-
- <%= f.input :videoconference_url, +
+ <%= f.input :videoconference_url, label: "Videoconference URL", as: :url, input_html: { @@ -184,8 +170,8 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
- <%= f.input :location_id, +
+ <%= f.input :location_id, label: "Location", collection: @locations, label_method: :name, @@ -195,26 +181,27 @@ class: "ts-flat w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", data: { searchable_select_target: "select" }, } %> -
-
+
+
-
- <%= f.input :videoconference_label, +
+ <%= f.input :videoconference_label, label: "Videoconference label", input_html: { placeholder: "Virtual event", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - <%= f.input :videoconference_passcode, + + <%= f.input :videoconference_passcode, label: "Videoconference passcode", hint: "Shown alongside the join link. The meeting ID/code is read from the URL automatically.", input_html: { placeholder: "e.g. 123456", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
+
- <%= f.input :short_description, + <%= f.input :short_description, label: "Short description", as: :text, hint: "Plain-text blurb for the \"Add to calendar\" links. Calendars don't render HTML — leave blank to fall back to the page description.", @@ -224,50 +211,53 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - <%# Continuing education — "CE hours offered" above 0 makes the event - CE-eligible (the gate for the CE card on registrations); the total cost - is what a registrant pays for credit (stored in cents). %> -
- -
- - +
-
+

Edit the CE hours ticket card and its details below, under Registration ticket callouts.

+
+
- <%# RIGHT: Autoshow display options %> -
-

Display options

-

Toggle which details appear on the event page.
Fields with no value will be ignored.

-
- <% tz = Time.zone %> - <% tz_abbr = @event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%Z") : Time.current.strftime("%Z") %> - <%= f.input :autoshow_pre_date_text, as: :boolean, label: ("Pre-date text" + (@event.pre_date_text.present? ? " #{h @event.pre_date_text}" : "")).html_safe %> - <%= f.input :autoshow_date, as: :boolean, label: ("Date " + (@event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%A, %B %-d, %Y") : Time.current.strftime("%A, %B %-d, %Y")) + "").html_safe %> - <%= f.input :autoshow_time, as: :boolean, label: ("Time " + (@event.start_date.present? && @event.end_date.present? ? @event.start_date.in_time_zone(tz).strftime("%-l:%M %P") + " – " + @event.end_date.in_time_zone(tz).strftime("%-l:%M %P") : "3:00 pm – 5:00 pm") + " " + tz_abbr + "").html_safe %> - <% cost_preview = @event.decorate.labelled_cost %> - <%= f.input :autoshow_cost, as: :boolean, label: ("Cost " + (cost_preview.present? ? h(cost_preview) : ""Cost: $X" or "Free event"") + "").html_safe %> - <%= f.input :autoshow_location, as: :boolean, label: ("Location" + (@event.location.present? ? " #{h @event.location.name}" : "")).html_safe %> - <% vc_domain = @event.videoconference_url.present? ? @event.decorate.videoconference_domain : nil %> - <%= f.input :autoshow_videoconference_link, as: :boolean, label: ("Videoconference link " + (vc_domain.present? ? "Join on #{vc_domain}" : "Join on [Domain]") + "").html_safe %> - <% vc_label = @event.videoconference_label.presence || "Virtual event" %> - <%= f.input :autoshow_videoconference_label, as: :boolean, label: ("Videoconference label #{h vc_label}").html_safe %> - <%= f.input :autoshow_registration_close, as: :boolean, label: ("Registration close Registration closes " + (@event.registration_close_date.present? ? @event.registration_close_date.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P") : 2.days.from_now.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P")) + " " + tz_abbr + "").html_safe %> - <%= f.input :autoshow_registration, as: :boolean, label: "Registration button Register + calendar links".html_safe %> -
-
+ <%# RIGHT: Autoshow display options %> +
+

Display options

+

Toggle which details appear on the event page.
Fields with no value will be ignored.

+ +
+ <% tz = Time.zone %> + <% tz_abbr = @event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%Z") : Time.current.strftime("%Z") %> + + <%= f.input :autoshow_pre_date_text, as: :boolean, label: ("Pre-date text" + (@event.pre_date_text.present? ? " #{h @event.pre_date_text}" : "")).html_safe %> + <%= f.input :autoshow_date, as: :boolean, label: ("Date " + (@event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%A, %B %-d, %Y") : Time.current.strftime("%A, %B %-d, %Y")) + "").html_safe %> + <%= f.input :autoshow_time, as: :boolean, label: ("Time " + (@event.start_date.present? && @event.end_date.present? ? @event.start_date.in_time_zone(tz).strftime("%-l:%M %P") + " – " + @event.end_date.in_time_zone(tz).strftime("%-l:%M %P") : "3:00 pm – 5:00 pm") + " " + tz_abbr + "").html_safe %> + + <% cost_preview = @event.decorate.labelled_cost %> + <%= f.input :autoshow_cost, as: :boolean, label: ("Cost " + (cost_preview.present? ? h(cost_preview) : ""Cost: $X" or "Free event"") + "").html_safe %> + <%= f.input :autoshow_location, as: :boolean, label: ("Location" + (@event.location.present? ? " #{h @event.location.name}" : "")).html_safe %> + + <% vc_domain = @event.videoconference_url.present? ? @event.decorate.videoconference_domain : nil %> + <%= f.input :autoshow_videoconference_link, as: :boolean, label: ("Videoconference link " + (vc_domain.present? ? "Join on #{vc_domain}" : "Join on [Domain]") + "").html_safe %> + <% vc_label = @event.videoconference_label.presence || "Virtual event" %> + <%= f.input :autoshow_videoconference_label, as: :boolean, label: ("Videoconference label #{h vc_label}").html_safe %> + <%= f.input :autoshow_registration_close, as: :boolean, label: ("Registration close Registration closes " + (@event.registration_close_date.present? ? @event.registration_close_date.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P") : 2.days.from_now.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P")) + " " + tz_abbr + "").html_safe %> + <%= f.input :autoshow_registration, as: :boolean, label: "Registration button Register + calendar links".html_safe %> +
+
@@ -275,29 +265,32 @@
<% if allowed_to?(:manage?, @event) %> -
+
- <%# On a failed save the event_forms aren't persisted, so re-read the - admin's registration choices from the submitted params instead of - the (now-empty) associations. %> + <%# + On a failed save the event_forms aren't persisted, so re-read the + admin's registration choices from the submitted params instead of + the (now-empty) associations. + %> <% resubmitted = params[:event].present? %> +
<% current_reg_form = @event.registration_form %> + <% default_form_id = if resubmitted params.dig(:event, :registration_form_id).presence&.to_i elsif current_reg_form @@ -307,45 +300,57 @@ else @registration_forms.find { |rf| rf.name == "Short Event Registration" }&.id end %> + -

- <%= link_to "View / Edit forms", forms_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %> -

+ +

<%= link_to "View / Edit forms", forms_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %>

Public registration +
<% if @event.public_registration_enabled? %> - Enabled + + Enabled <% else %> - Disabled + + Disabled <% end %> - Change + + + Change +
+ <% if @event.event_forms.registration.exists? %> <%= link_to new_event_public_registration_path(@event), class: "inline-flex items-center gap-1 text-sm text-blue-700 hover:text-blue-900 underline mt-2", target: "_blank" do %> - View public registration page + + View public registration page <% end %> <% end %>
@@ -353,131 +358,156 @@
Registration options +
- +

Skip the registration form for logged-in users. Logged-out visitors still use the form.

+
- +

Add an at-a-glance list of dates, time, platform/location, fee, and deadline above the registration form. Pulled from this event's details.

+
<%= f.label :hint_dates, "Dates note", class: "block text-xs font-medium text-gray-700" %> + <%= f.text_field :hint_dates, placeholder: "e.g. must attend both days", class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %>
-
- <%= f.label :hint_times, "Time note", class: "block text-xs font-medium text-gray-700" %> - <%= f.text_field :hint_times, + +
<%= f.label :hint_times, "Time note", class: "block text-xs font-medium text-gray-700" %><%= f.text_field :hint_times, placeholder: "e.g. both days", - class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %> -
+ class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %>
+
<%= f.label :hint_registration_cost, "Fee note", class: "block text-xs font-medium text-gray-700" %> + <%= f.text_field :hint_registration_cost, placeholder: "e.g. due within 3 weeks of registration", class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %> +
+ +

Shown in grey beside the Dates, Time, and Fee rows on the details panel.

-

Shown in grey beside the Dates, Time, and Fee rows on the details panel.

-
- <% if @scholarship_forms.any? %> -
- <% if @scholarship_forms.size == 1 %> - <% single_form = @scholarship_forms.first %> -
+ <% end %>
@@ -491,9 +521,11 @@ id="page_content_button" class=" flex items-center justify-between cursor-pointer w-full - bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md" + bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md + " data-action="dropdown#toggle" - data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]'> + data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]' + > Event show page @@ -505,43 +537,50 @@ <%= rhino_editor(f, :header, label: "Header content") %> <% if f.object.errors[:rhino_header].present? %> -
- Field <%= f.object.errors[:rhino_header].first %> -
+
Field <%= f.object.errors[:rhino_header].first %>
<% end %>
-
- <%= rhino_editor(f, :description, label: "Description") %> -
+
<%= rhino_editor(f, :description, label: "Description") %>
<%# ---- REGISTRATION TICKET CALLOUTS (built-in + custom ticket call-outs) ---- %> - <%# ?expand=callouts (from the ticket's "Edit event" link) auto-opens this - section via a dropdown expand target; scroll-mt keeps the anchor clear of - any sticky chrome. %> + <%# + ?expand=callouts (from the ticket's "Edit event" link) auto-opens this + section via a dropdown expand target; scroll-mt keeps the anchor clear of + any sticky chrome. + %> +
<% end %> + <% if @continuing_education_forms&.any? %>
<% if @continuing_education_forms.size == 1 %> <% single_form = @continuing_education_forms.first %> - + class: "peer rounded border-gray-300" %> + Enable continuing education

Allow registrants to request CE hours.

+ +

Edit the CE hours ticket card and its details below, under Registration ticket callouts.

<% if @event.continuing_education_form.present? %> <%= link_to new_event_public_registration_path(@event), class: "inline-flex items-center gap-1 text-xs text-blue-700 hover:text-blue-900 underline mt-1", target: "_blank" do %> - Preview CE form + + Preview CE form <% end %> <% end %> <% else %> @@ -500,6 +488,7 @@ end %>

Select a continuing education form for registrants to request CE hours.

+ <% end %>
<% end %> diff --git a/spec/system/ce_form_toggle_on_event_edit_spec.rb b/spec/system/ce_form_toggle_on_event_edit_spec.rb new file mode 100644 index 000000000..076c5ff17 --- /dev/null +++ b/spec/system/ce_form_toggle_on_event_edit_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe "CE form toggle on event edit", type: :system do + let(:admin) { create(:user, :with_person, :admin) } + let(:event) { create(:event) } + let(:ce_form) { create(:form, role: "continuing_education", name: "CE Request") } + + before do + ce_form # ensure the form exists so controller populates @continuing_education_forms + sign_in admin + end + + context "when CE form is not attached" do + it "hides hours/cost fields" do + visit edit_event_path(event) + expect(page).to have_unchecked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :hidden) + end + end + + context "when CE form is attached" do + before do + create(:event_form, event: event, form: ce_form, role: "continuing_education") + visit edit_event_path(event) + end + + it "shows hours/cost fields" do + expect(page).to have_checked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :visible) + end + end +end From ef257cf3887cbb3562803992a28ae62441c0d36e Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:51:16 -0400 Subject: [PATCH 09/13] add ce payment via stripe --- app/controllers/events/callouts_controller.rb | 54 +++++++++++++++++++ app/models/concerns/pay_charge_extensions.rb | 34 +++++++++++- app/views/events/callouts/ce.html.erb | 9 ++++ config/routes.rb | 1 + 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index ef6d53339..253d99714 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -60,6 +60,23 @@ def scholarship # CE hours status: hours, amount owed, and license number. def ce + case params[:checkout] + when "success" + flash.now[:notice] = "Your CE payment was successful." + when "cancelled" + flash.now[:alert] = "CE payment was cancelled. You can try again whenever you're ready." + end + end + + # Pay the CE balance via Stripe Checkout. + def pay_ce + ce_registration = @event_registration.continuing_education_registrations.first + unless ce_registration&.remaining_cost&.positive? + redirect_to registration_ce_path(@event_registration.slug), alert: "No CE payment is due." + return + end + + redirect_to_ce_stripe_checkout(ce_registration) end # Public license entry from the CE callout (type, number, issuing state, and @@ -202,5 +219,42 @@ def resource_card(icon:, title:, subtitle:, href:, target: "_blank", trailing_ic MagicTicketCallouts::Card.new(icon_class: icon, color: "blue", title: title, subtitle: subtitle, href: href, target: target, trailing_icon: trailing_icon) end + + def redirect_to_ce_stripe_checkout(ce_registration) + person = @event_registration.registrant + amount = ce_registration.remaining_cost + + person.set_payment_processor :stripe + + checkout_session = person.payment_processor.checkout( + mode: "payment", + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + payment_intent_data: { + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + description: "CE Hours: #{@event.title}" + }, + line_items: [ { + price_data: { + currency: "usd", + product_data: { name: "CE Hours: #{@event.title}" }, + unit_amount: amount + }, + quantity: 1 + } ], + success_url: registration_ce_url(@event_registration.slug, checkout: "success"), + cancel_url: registration_ce_url(@event_registration.slug, checkout: "cancelled") + ) + + @event_registration.update!(checkout_session_id: checkout_session.id) + redirect_to checkout_session.url, allow_other_host: true, status: :see_other + end end end diff --git a/app/models/concerns/pay_charge_extensions.rb b/app/models/concerns/pay_charge_extensions.rb index 54cadffe5..a5dc7d44d 100644 --- a/app/models/concerns/pay_charge_extensions.rb +++ b/app/models/concerns/pay_charge_extensions.rb @@ -12,7 +12,9 @@ def create_external_processor_payment return if ExternalProcessorPayment.exists?(pay_charge_id: id) return unless object["paid"] == true - if (event_registration_id = metadata["event_registration_id"]) + if (ce_registration_id = metadata["ce_registration_id"]) + create_ce_payment(ce_registration_id) + elsif (event_registration_id = metadata["event_registration_id"]) create_event_registration_payment(event_registration_id) elsif (form_submission_id = metadata["form_submission_id"]) create_bulk_payment(form_submission_id) @@ -54,6 +56,36 @@ def create_event_registration_payment(event_registration_id) registration.update!(payment_unresolved: false) end + def create_ce_payment(ce_registration_id) + ce_registration = ContinuingEducationRegistration.find_by(id: ce_registration_id) + return unless ce_registration + + person = ce_registration.event_registration&.registrant + return unless person + + payment = ExternalProcessorPayment.create!( + stripe_charge_id: processor_id, + external_origin: false, + person: person, + amount_cents: amount, + amount_cents_remaining: amount, + currency: currency, + pay_charge_id: id, + metadata: metadata.merge(stripe_charge: object) + ) + + remaining_needed = ce_registration.remaining_cost + allocation_amount = [ amount, remaining_needed ].min + + if allocation_amount > 0 + Allocation.create!( + source: payment, + allocatable: ce_registration, + amount: allocation_amount + ) + end + end + def create_bulk_payment(form_submission_id) submission = FormSubmission.find_by(id: form_submission_id) return unless submission&.role == "bulk_payment" diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 040786762..cf94a61d6 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -62,6 +62,15 @@
<%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
+ + <% if ce_registration && ce_registration.remaining_cost.positive? %> +
+ <%= button_to "Pay with Credit Card", + registration_ce_pay_path(@event_registration.slug), + data: { turbo: false }, + class: "btn btn-accent px-6 py-2 text-sm uppercase font-telefon" %> +
+ <% end %>
<%# diff --git a/config/routes.rb b/config/routes.rb index b08eb45f0..00eea12ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request + post "registration/:slug/ce/pay", to: "events/callouts#pay_ce", as: :registration_ce_pay get "registration/:slug/forms", to: "events/callouts#forms", as: :registration_forms get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource From af3989da70004dfead7c083d4a6e910a834ddaa3 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:09:26 -0400 Subject: [PATCH 10/13] add payment/discount info to callout --- app/views/events/callouts/ce.html.erb | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index cf94a61d6..8fc7987eb 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -52,17 +52,35 @@
<%= plain_number(@event_registration.ce_hours_total) || "—" %>
-
-
Cost
-
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
-
-
Status
<%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
+

+ Cost + <%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %> +

+ + <% if ce_registration&.discounted? %> +

+ Discount + −<%= dollars_from_cents(ce_registration.discount_sum) %> +

+ <% end %> + +

+ Amount paid + <%= dollars_from_cents(ce_registration&.payments_sum.to_i) %> + <% if ce_registration&.paid_in_full? || simulate_ce_paid %> + + + Paid in full + + <% end %> +

+ <% if ce_registration && ce_registration.remaining_cost.positive? %>
<%= button_to "Pay with Credit Card", From d3c36b68dae1cf24dc0135de8eb751fdaede6ca4 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:15:45 -0400 Subject: [PATCH 11/13] add specs --- app/models/event.rb | 1 - .../concerns/pay_charge_extensions_spec.rb | 58 ++++++++++ spec/requests/events/callouts_spec.rb | 103 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/app/models/event.rb b/app/models/event.rb index 6204660b4..69f8e0e62 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -359,5 +359,4 @@ def build_public_registration_form event_forms.create!(form: form, role: "registration") end - end diff --git a/spec/models/concerns/pay_charge_extensions_spec.rb b/spec/models/concerns/pay_charge_extensions_spec.rb index bf9a82df2..831a77151 100644 --- a/spec/models/concerns/pay_charge_extensions_spec.rb +++ b/spec/models/concerns/pay_charge_extensions_spec.rb @@ -58,6 +58,64 @@ end end + describe "CE payment via ce_registration_id metadata" do + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, cost_cents: 15_00) + end + + let(:charge_object) do + { + "id" => "ch_ce_456", + "object" => "charge", + "amount" => 10_00, + "currency" => "usd", + "paid" => true, + "metadata" => { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id }, + "refunds" => { "object" => "list", "data" => [], "has_more" => false } + } + end + + let!(:pay_charge) do + Pay::Charge.create!( + customer: pay_customer, + processor_id: "ch_ce_456", + amount: 10_00, + amount_refunded: 0, + currency: "usd", + object: charge_object, + metadata: { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id } + ) + end + + it "creates an ExternalProcessorPayment" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment).to have_attributes( + amount_cents: 10_00, + person: person, + pay_charge: pay_charge + ) + end + + it "creates an Allocation against the CE registration" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + allocation = payment.allocations.first + expect(allocation.allocatable).to eq(ce_registration) + expect(allocation.amount).to eq(10_00) + end + + it "allocates against the CE registration even when event_registration_id is also present" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment.allocations.first.allocatable).to eq(ce_registration) + expect(Allocation.where(allocatable: registration, source: payment)).to be_empty + end + + it "is idempotent" do + expect do + pay_charge.update!(object: charge_object) + end.not_to change(ExternalProcessorPayment, :count) + end + end + describe "refund sync" do let(:refunded_object) do charge_object.deep_merge( diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index ff87c00b3..28962d996 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -65,4 +65,107 @@ end end end + + describe "GET /registration/:slug/ce" do + context "when CE is registered" do + let(:event) { create(:event) } + + before do + license = create(:professional_license, person: registration.registrant, number: nil) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + it "renders the CE page" do + get registration_ce_path(registration.slug) + expect(response).to have_http_status(:success) + end + + context "with checkout=success" do + it "shows a success flash" do + get registration_ce_path(registration.slug, checkout: "success") + expect(flash[:notice]).to eq("Your CE payment was successful.") + end + end + + context "with checkout=cancelled" do + it "shows a cancelled flash" do + get registration_ce_path(registration.slug, checkout: "cancelled") + expect(flash[:alert]).to eq("CE payment was cancelled. You can try again whenever you're ready.") + end + end + end + + context "when CE is not registered" do + let(:event) { create(:event) } + + it "renders the opt-in form" do + get registration_ce_path(registration.slug) + expect(response).to have_http_status(:success) + expect(response.body).to include("Request CE credit") + end + end + end + + describe "POST /registration/:slug/ce/pay" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 15_000) } + let(:fake_session) { double(url: "https://checkout.stripe.com/test", id: "cs_test_123") } + + before do + license = create(:professional_license, person: registration.registrant, number: "LIC-1") + create(:continuing_education_registration, event_registration: registration, + professional_license: license, cost_cents: 15_000) + + fake_processor = double(checkout: fake_session) + allow_any_instance_of(Person).to receive(:set_payment_processor) + allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + end + + it "redirects to Stripe Checkout when a CE balance is due" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to("https://checkout.stripe.com/test") + expect(response.status).to eq(303) + end + + it "includes ce_registration_id and event_registration_id in the checkout metadata" do + captured = nil + fake_processor = double + allow(fake_processor).to receive(:checkout) { |params| captured = params; fake_session } + allow_any_instance_of(Person).to receive(:set_payment_processor) + allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + + post registration_ce_pay_path(registration.slug) + + expect(captured[:metadata]).to include( + ce_registration_id: registration.continuing_education_registrations.first.id, + event_registration_id: registration.id + ) + end + + it "updates the checkout_session_id on the registration" do + post registration_ce_pay_path(registration.slug) + expect(registration.reload.checkout_session_id).to eq("cs_test_123") + end + + context "when no CE registration exists" do + before { registration.continuing_education_registrations.destroy_all } + + it "redirects with an alert" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:alert]).to eq("No CE payment is due.") + end + end + + context "when the CE balance is zero" do + before do + registration.continuing_education_registrations.first.update!(cost_cents: 0) + end + + it "redirects with an alert" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:alert]).to eq("No CE payment is due.") + end + end + end end From 99d3e12e354eb74d2d1a440c3a14fdc998519b0a Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:26:47 -0400 Subject: [PATCH 12/13] rubocop --- db/seeds/dev/events_management.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index c2a6e749b..19a995974 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -292,6 +292,7 @@ ef.form = bulk_payment_form end end +end # The flagship training runs on Zoom — drive the platform from event settings so # it shows as a badge in the public registration header (rather than living in the From 0a730e555c48a827a54e923e5aff13ff5bcaec08 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:30:40 -0400 Subject: [PATCH 13/13] brakeman --- config/brakeman.ignore | 148 +++++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 63 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 7d8b4112e..785b764f1 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -34,6 +34,63 @@ ], "note": "Stakeholder requirement for admin to add google analytics" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/events/confirm_reminder.html.erb", + "line": 58, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", + "render_path": [ + { + "type": "controller", + "class": "EventsController", + "method": "confirm_reminder", + "line": 397, + "file": "app/controllers/events_controller.rb", + "rendered": { + "name": "events/confirm_reminder", + "file": "app/views/events/confirm_reminder.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "events/confirm_reminder" + }, + "user_input": "params[:custom_message].to_s", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." + }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "6b0a218f30eb40af7b6cf4ec15de35003eb42e1cea35ecd6acb69139643a338b", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/events/callouts_controller.rb", + "line": 257, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", + "render_path": null, + "location": { + "type": "method", + "class": "Events::CalloutsController", + "method": "redirect_to_ce_stripe_checkout" + }, + "user_input": "EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "known redirect for stripe" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -49,7 +106,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -75,7 +132,7 @@ "check_name": "Redirect", "message": "Possible unprotected redirect", "file": "app/controllers/people_controller.rb", - "line": 119, + "line": 148, "link": "https://brakemanscanner.org/docs/warning_types/redirect/", "code": "redirect_to(Person.find(params[:id]).payment_processor.checkout(:mode => \"payment\", :metadata => ({ :person_id => Person.find(params[:id]).id }), :payment_intent_data => ({ :metadata => ({ :person_id => Person.find(params[:id]).id }) }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => \"Donation to A Window Between Worlds\" }), :unit_amount => (((params[:amount].to_i * 100).to_i or 1000)) }), :quantity => 1 }]), :success_url => person_url(Person.find(params[:id]), :checkout => \"success\"), :cancel_url => person_url(Person.find(params[:id]), :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", "render_path": null, @@ -93,49 +150,49 @@ }, { "warning_type": "Mass Assignment", - "warning_code": 70, - "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", - "check_name": "MassAssignment", - "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", - "file": "app/controllers/reports_controller.rb", - "line": 263, + "warning_code": 105, + "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/forms_controller.rb", + "line": 138, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params[:quotes].permit!", + "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", "render_path": null, "location": { "type": "method", - "class": "ReportsController", - "method": "quotes_params" + "class": "FormsController", + "method": "form_params" }, - "user_input": null, + "user_input": ":role", "confidence": "Medium", "cwe_id": [ 915 ], - "note": "" + "note": "admin only" }, { "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/forms_controller.rb", - "line": 138, + "warning_code": 70, + "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", + "check_name": "MassAssignment", + "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", + "file": "app/controllers/reports_controller.rb", + "line": 263, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", + "code": "params[:quotes].permit!", "render_path": null, "location": { "type": "method", - "class": "FormsController", - "method": "form_params" + "class": "ReportsController", + "method": "quotes_params" }, - "user_input": ":role", + "user_input": null, "confidence": "Medium", "cwe_id": [ 915 ], - "note": "admin only" + "note": "" }, { "warning_type": "Cross-Site Scripting", @@ -152,7 +209,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -178,7 +235,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notifications/show.html.erb", - "line": 190, + "line": 196, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Notification.find(params[:id]).email_body_html", "render_path": [ @@ -204,42 +261,7 @@ 79 ], "note": "email_body_html is the server-rendered HTML of a notification email, generated from trusted mailer templates rather than user-supplied free text. It is rendered intentionally as a preview on notifications/show, which is gated to admin-or-owner (NotificationPolicy#show?), so no untrusted input reaches the page." - }, - { - "warning_type": "Cross-Site Scripting", - "warning_code": 2, - "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", - "check_name": "CrossSiteScripting", - "message": "Unescaped parameter value", - "file": "app/views/events/confirm_reminder.html.erb", - "line": 53, - "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", - "render_path": [ - { - "type": "controller", - "class": "EventsController", - "method": "confirm_reminder", - "line": 316, - "file": "app/controllers/events_controller.rb", - "rendered": { - "name": "events/confirm_reminder", - "file": "app/views/events/confirm_reminder.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "events/confirm_reminder" - }, - "user_input": "params[:custom_message].to_s", - "confidence": "Weak", - "cwe_id": [ - 79 - ], - "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." } ], - "brakeman_version": "8.0.4", - "updated": "2026-06-19 10:05:46 -0400" + "brakeman_version": "8.0.5" }