diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
index e698aa54eb..e46c5ff6af 100644
--- a/app/controllers/people_controller.rb
+++ b/app/controllers/people_controller.rb
@@ -394,6 +394,8 @@ def person_params
:license_number,
:license_type,
:credentials,
+ :racial_ethnic_identity,
+ :mailing_list_consented,
:bio, :shoutout_text, :notes,
:display_name_preference,
:pronouns,
diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb
index a54f1a30e6..4dca0be0a3 100644
--- a/app/controllers/scholarships_controller.rb
+++ b/app/controllers/scholarships_controller.rb
@@ -187,19 +187,39 @@ def load_scholarship_submission
return unless @allocatable.respond_to?(:event)
@event = @allocatable.event
- form = @event&.registration_form
+ return unless @event
+
+ form, fields = scholarship_form_and_fields
return unless form
- @form_submission = form.form_submissions.find_by(person: @scholarship.recipient)
+ @form_submission = scholarship_submission_for(form)
answers = @form_submission ? @form_submission.form_answers.index_by(&:form_field_id) : {}
- @scholarship_answers = form.form_fields
- .select { |field| field.section == "scholarship" || field.scholarship_only? }
+ @scholarship_answers = fields
.reject { |field| field.group_header? || field.no_user_input? }
.sort_by { |field| field.position.to_i }
.map { |field| [ field, answers[field.id] ] }
end
+ # The scholarship questions live on the event's dedicated scholarship form
+ # (role: "scholarship") when one is linked; older events embed them in a
+ # "scholarship" section of the registration form instead. Prefer the former,
+ # fall back to the latter so both layouts surface the answers.
+ def scholarship_form_and_fields
+ if (scholarship_form = @event.scholarship_form)
+ [ scholarship_form, scholarship_form.form_fields.to_a ]
+ elsif (registration_form = @event.registration_form)
+ [ registration_form, registration_form.form_fields.select { |field| field.section == "scholarship" || field.scholarship_only? } ]
+ else
+ [ nil, [] ]
+ end
+ end
+
+ def scholarship_submission_for(form)
+ form.form_submissions.find_by(person: @scholarship.recipient, role: "scholarship") ||
+ form.form_submissions.find_by(person: @scholarship.recipient)
+ end
+
def locate_allocatable
sgid = params[:allocatable_sgid] || params.dig(:scholarship, :allocatable_sgid)
GlobalID::Locator.locate_signed(sgid) if sgid
diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb
index 25ac9f890a..92f4e8fdea 100644
--- a/app/helpers/event_helper.rb
+++ b/app/helpers/event_helper.rb
@@ -143,7 +143,7 @@ def resolve_answer_text(field, submitted_answer)
case field&.field_identifier
when *FormField::SECTOR_FIELD_IDENTIFIERS
resolve.call(Sector)
- when "primary_age_group", "additional_age_group"
+ when *FormField::AGE_GROUP_FIELD_IDENTIFIERS
resolve.call(Category)
else
submitted_answer
diff --git a/app/models/form_field.rb b/app/models/form_field.rb
index c8ee2feba6..00dfa8b5c3 100644
--- a/app/models/form_field.rb
+++ b/app/models/form_field.rb
@@ -32,17 +32,28 @@ class FormField < ApplicationRecord
# submitted value for these is a Sector id (as a string).
SECTOR_FIELD_IDENTIFIERS = (ADDITIONAL_SECTOR_FIELD_IDENTIFIERS + PRIMARY_SECTOR_FIELD_IDENTIFIERS).freeze
+ # Single-select "primary age group" field identifiers. Primary has never been
+ # renamed, so this is just the canonical name — kept as a list for symmetry
+ # with the sector fields and the multi-select additional field below.
+ PRIMARY_AGE_GROUP_FIELD_IDENTIFIERS = %w[primary_age_group].freeze
+
+ # Multi-select "additional age group(s)" field identifiers. "additional_age_group"
+ # is the canonical name new forms are built with; the pluralized
+ # "additional_age_groups" is also accepted so older forms carrying it keep
+ # resolving (mirrors the legacy coverage the sector fields have).
+ ADDITIONAL_AGE_GROUP_FIELD_IDENTIFIERS = %w[additional_age_group additional_age_groups].freeze
+
+ # Every age-group field identifier, primary and additional.
+ AGE_GROUP_FIELD_IDENTIFIERS = (PRIMARY_AGE_GROUP_FIELD_IDENTIFIERS + ADDITIONAL_AGE_GROUP_FIELD_IDENTIFIERS).freeze
+
# Field identifiers whose selectable options are sourced dynamically from a
# CategoryType's published categories. The submitted value is a Category id
# (as a string). Maps the field identifier to its backing CategoryType name.
- # Both the "primary" and "additional" age group fields are backed by the
- # published AgeRange categories. Unlike the sector fields, age groups have no
- # catch-all option — the additional sector field keeps "Other", but neither age
- # field offers one.
- DYNAMIC_FIELD_CATEGORY_TYPES = {
- "primary_age_group" => "AgeRange",
- "additional_age_group" => "AgeRange"
- }.freeze
+ # Every "primary" and "additional" age group field (canonical or legacy) is
+ # backed by the published AgeRange categories. Unlike the sector fields, age
+ # groups have no catch-all option — the additional sector field keeps "Other",
+ # but no age field offers one.
+ DYNAMIC_FIELD_CATEGORY_TYPES = AGE_GROUP_FIELD_IDENTIFIERS.index_with { "AgeRange" }.freeze
# The payment-method field. Its answer options ("Credit card (now)", etc.) are
# wired to Stripe charge logic in the controllers, so they must not be edited
diff --git a/app/models/person.rb b/app/models/person.rb
index 424dd07d15..a697c6f73f 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -138,6 +138,28 @@ def sector_list
sectors.pluck(:name)
end
+ # Virtual checkbox for the admin person form. Presence of a consent timestamp is
+ # the source of truth; this lets an admin grant or withdraw consent. Withdrawing
+ # clears both the timestamp and its source; granting (when none is on file)
+ # stamps the time and records that an admin did it. Re-checking an existing
+ # consent leaves the original timestamp/source intact.
+ def mailing_list_consented
+ mailing_list_consent_at.present?
+ end
+
+ def mailing_list_consented=(value)
+ consented = ActiveModel::Type::Boolean.new.cast(value)
+
+ if consented
+ return if mailing_list_consent_at.present?
+ self.mailing_list_consent_at = Time.current
+ self.mailing_list_consent_source = "Admin update"
+ else
+ self.mailing_list_consent_at = nil
+ self.mailing_list_consent_source = nil
+ end
+ end
+
def name
case display_name_preference
when "full_name"
@@ -230,7 +252,7 @@ def remote_search_label
# Field identifiers whose "Other" free text maps onto the category-backed
# profile fields shown on the edit page.
- OTHER_WORKSHOP_SETTING_IDENTIFIERS = %w[primary_age_group additional_age_group].freeze
+ OTHER_WORKSHOP_SETTING_IDENTIFIERS = FormField::AGE_GROUP_FIELD_IDENTIFIERS
# Free-text "Other" sectors the person typed on registration forms.
# They can't be Sector records, so they're surfaced beside the sector tags.
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index ff7c3e3e82..aabe5f87f2 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -44,13 +44,17 @@ def initialize(event:, form:, form_params:, scholarship_requested: false, person
def call
ActiveRecord::Base.transaction do
person = find_or_create_person
+ sync_person_profile(person)
create_mailing_address(person) if field_value("mailing_city").present?
create_phone_contact(person) if field_value("phone").present?
organization = find_organization if field_value(ORGANIZATION_NAME_IDENTIFIER).present?
- create_affiliation(person, organization) if organization
- create_agency_address(organization) if organization && field_value("agency_city").present?
+ if organization
+ create_affiliation(person, organization)
+ sync_organization_profile(organization)
+ create_agency_address(organization) if field_value("agency_city").present?
+ end
assign_tags(person, organization)
@@ -60,6 +64,7 @@ def call
existing.update!(ce_credit_requested: true, ce_hours_requested: ce_hours_requested) if ce_credit_requested?
existing.update!(w9_requested: true) if w9_requested?
existing.update!(invoice_requested: true) if invoice_requested?
+ apply_value(existing, :expected_payment_method, field_value("payment_method"))
if existing.status == "cancelled"
existing.update!(status: "registered")
send_notifications(existing)
@@ -166,6 +171,55 @@ def find_matching_person(last_name:, email:)
.first
end
+ # Populate the structured columns that the registration form collects but that
+ # we historically stored only as form answers. A non-blank submitted value
+ # overwrites whatever was on file: the latest registration is treated as the
+ # freshest source of truth, and the prior value is preserved in the audit trail
+ # — every model includes AhoyTrackable, whose after_update logs an Ahoy::Event
+ # capturing the change. A blank answer never clobbers existing data.
+ def sync_person_profile(person)
+ apply_value(person, :racial_ethnic_identity, field_value("racial_ethnic_identity"))
+ record_mailing_list_consent(person)
+ end
+
+ # Consent is opt-in only and recorded once. An affirmative answer grants
+ # consent (stamping the time and where it came from) when none is on file; we
+ # never clear it from here — withdrawal is a separate, deliberate action — and
+ # we don't keep re-stamping a registrant who already consented.
+ def record_mailing_list_consent(person)
+ return if person.mailing_list_consent_at.present?
+ return unless mailing_list_consent_given?
+
+ person.update!(
+ mailing_list_consent_at: Time.current,
+ mailing_list_consent_source: mailing_list_consent_source
+ )
+ end
+
+ # Identify the event by start date *and* title — many trainings share a title,
+ # so the leading date is what makes the consent source traceable to one event,
+ # e.g. "2026-06-23 Facilitator Training registration".
+ def mailing_list_consent_source
+ [ @event.start_date&.to_date&.iso8601, "#{@event.title} registration" ].compact.join(" ")
+ end
+
+ def mailing_list_consent_given?
+ Array(field_value("communication_consent")).any? { |value| value.to_s.strip.present? }
+ end
+
+ def sync_organization_profile(organization)
+ apply_value(organization, :website_url, field_value("agency_website"))
+ apply_value(organization, :agency_type, field_value("agency_type"))
+ end
+
+ # Write value onto attribute when a non-blank value was submitted, overwriting
+ # any existing value. A no-op when the value is unchanged (update! records no
+ # change, so no spurious audit event).
+ def apply_value(record, attribute, value)
+ return if value.blank?
+ record.update!(attribute => value.strip)
+ end
+
def create_mailing_address(person)
new_city = field_value("mailing_city")&.strip
new_state = field_value("mailing_state")&.strip
@@ -182,6 +236,7 @@ def create_mailing_address(person)
primary: true,
inactive: false
)
+ apply_value(existing, :country, field_value("mailing_country"))
return existing
end
@@ -192,6 +247,7 @@ def create_mailing_address(person)
city: new_city,
state: new_state,
zip_code: field_value("mailing_zip"),
+ country: field_value("mailing_country")&.strip,
locality: "Unknown",
address_type: field_value("mailing_address_type")&.downcase || "unknown",
primary: true
@@ -256,6 +312,7 @@ def create_agency_address(organization)
primary: true,
inactive: false
)
+ apply_value(existing, :country, field_value("agency_country"))
return existing
end
@@ -266,6 +323,7 @@ def create_agency_address(organization)
city: new_city,
state: new_state,
zip_code: field_value("agency_zip"),
+ country: field_value("agency_country")&.strip,
locality: "Unknown",
address_type: "work",
primary: true
@@ -273,10 +331,10 @@ def create_agency_address(organization)
end
def assign_tags(person, organization)
- primary_sector_ids = collect_sector_ids(FormField::PRIMARY_SECTOR_FIELD_IDENTIFIERS)
- additional_sector_ids = collect_sector_ids(FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS)
- primary_age_ids = collect_ids_from_checkboxes("primary_age_group")
- additional_age_ids = collect_ids_from_checkboxes("additional_age_group")
+ primary_sector_ids = collect_ids(FormField::PRIMARY_SECTOR_FIELD_IDENTIFIERS)
+ additional_sector_ids = collect_ids(FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS)
+ primary_age_ids = collect_ids(FormField::PRIMARY_AGE_GROUP_FIELD_IDENTIFIERS)
+ additional_age_ids = collect_ids(FormField::ADDITIONAL_AGE_GROUP_FIELD_IDENTIFIERS)
if primary_sector_ids.any? || additional_sector_ids.any?
person.tag_sectors(primary_ids: primary_sector_ids, additional_ids: additional_sector_ids)
@@ -292,7 +350,7 @@ def assign_tags(person, organization)
end
end
- def collect_sector_ids(identifiers)
+ def collect_ids(identifiers)
identifiers.flat_map { |id| collect_ids_from_checkboxes(id) }
end
@@ -311,7 +369,8 @@ def create_event_registration(person)
ce_credit_requested: ce_credit_requested?,
ce_hours_requested: ce_hours_requested,
w9_requested: w9_requested?,
- invoice_requested: invoice_requested?
+ invoice_requested: invoice_requested?,
+ expected_payment_method: field_value("payment_method")&.strip.presence
)
end
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb
index 13a1091ad2..0a8c76e330 100644
--- a/app/views/event_registrations/_form.html.erb
+++ b/app/views/event_registrations/_form.html.erb
@@ -291,6 +291,12 @@
<% end %>
<% end %>
+ <% if f.object.expected_payment_method.present? %>
+
+ Expected payment method:
+ <%= f.object.expected_payment_method %>
+
+ <% end %>
<%= render "payment_history", event_registration: f.object %>
<% if allowed_to?(:index?, with: NotificationPolicy) %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb
index f3f31acacb..55a94a55f8 100644
--- a/app/views/people/_form.html.erb
+++ b/app/views/people/_form.html.erb
@@ -371,6 +371,23 @@
input_html: { class: "w-full" },
wrapper_html: { class: "w-full" } %>
+
+
+ <%= f.input :racial_ethnic_identity,
+ label: "Racial/ethnic identity",
+ input_html: { class: "w-full" },
+ wrapper_html: { class: "w-full" } %>
+
+
+
+ <%= f.input :mailing_list_consented,
+ as: :boolean,
+ label: "Consented to mailing list",
+ hint: f.object.mailing_list_consent_at.present? ?
+ "Consented #{f.object.mailing_list_consent_at.to_date.to_fs(:long)} (#{f.object.mailing_list_consent_source}). Uncheck to withdraw." :
+ "No consent on file. Check to record consent.",
+ wrapper_html: { class: "w-full" } %>
+
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb
index d8fdc59d3b..c82d6cdeb8 100644
--- a/app/views/people/show.html.erb
+++ b/app/views/people/show.html.erb
@@ -165,6 +165,26 @@
<%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups %>
<% end %>
+
+ <% if allowed_to?(:manage?, Person) && @person.racial_ethnic_identity.present? %>
+
+
Racial/ethnic identity
+
<%= @person.racial_ethnic_identity %>
+
+ <% end %>
+
+ <% if allowed_to?(:manage?, Person) %>
+
+
Mailing list consent
+ <% if @person.mailing_list_consent_at.present? %>
+
+ Consented <%= @person.mailing_list_consent_at.to_date.to_fs(:long) %><%= " — #{@person.mailing_list_consent_source}" if @person.mailing_list_consent_source.present? %>
+
+ <% else %>
+
No consent on file.
+ <% end %>
+
+ <% end %>
diff --git a/app/views/scholarships/_form_submission.html.erb b/app/views/scholarships/_form_submission.html.erb
index 992d69a522..2ab3fe1c97 100644
--- a/app/views/scholarships/_form_submission.html.erb
+++ b/app/views/scholarships/_form_submission.html.erb
@@ -1,6 +1,6 @@
-<%# ---- Form submission — the recipient's scholarship-section answers from their
- registration form, with a jump link to the full public submission (where the
- complete application, if any, lives). ---- %>
+<%# ---- Form submission — the recipient's scholarship application answers (from
+ the event's dedicated scholarship form, or a "scholarship" section embedded in
+ the registration form), with a jump link to the full public submission. ---- %>
<% submission_params = @allocatable.try(:slug).present? ? { reg: @allocatable.slug } : { person_id: @scholarship.recipient_id } %>
<% answered = @scholarship_answers.to_a.any? { |_field, response| response&.submitted_answer.present? } %>
diff --git a/db/migrate/20260622202122_add_racial_ethnic_identity_to_people.rb b/db/migrate/20260622202122_add_racial_ethnic_identity_to_people.rb
new file mode 100644
index 0000000000..3eca2aa6fc
--- /dev/null
+++ b/db/migrate/20260622202122_add_racial_ethnic_identity_to_people.rb
@@ -0,0 +1,9 @@
+class AddRacialEthnicIdentityToPeople < ActiveRecord::Migration[8.1]
+ def up
+ add_column :people, :racial_ethnic_identity, :string
+ end
+
+ def down
+ remove_column :people, :racial_ethnic_identity, if_exists: true
+ end
+end
diff --git a/db/migrate/20260623001312_add_expected_payment_method_to_event_registrations.rb b/db/migrate/20260623001312_add_expected_payment_method_to_event_registrations.rb
new file mode 100644
index 0000000000..728208393c
--- /dev/null
+++ b/db/migrate/20260623001312_add_expected_payment_method_to_event_registrations.rb
@@ -0,0 +1,9 @@
+class AddExpectedPaymentMethodToEventRegistrations < ActiveRecord::Migration[8.1]
+ def up
+ add_column :event_registrations, :expected_payment_method, :string
+ end
+
+ def down
+ remove_column :event_registrations, :expected_payment_method, if_exists: true
+ end
+end
diff --git a/db/migrate/20260623001313_add_mailing_list_consent_to_people.rb b/db/migrate/20260623001313_add_mailing_list_consent_to_people.rb
new file mode 100644
index 0000000000..58379165b1
--- /dev/null
+++ b/db/migrate/20260623001313_add_mailing_list_consent_to_people.rb
@@ -0,0 +1,11 @@
+class AddMailingListConsentToPeople < ActiveRecord::Migration[8.1]
+ def up
+ add_column :people, :mailing_list_consent_at, :datetime
+ add_column :people, :mailing_list_consent_source, :string
+ end
+
+ def down
+ remove_column :people, :mailing_list_consent_at, if_exists: true
+ remove_column :people, :mailing_list_consent_source, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b33af524e5..cd51fa9919 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_06_22_154549) do
+ActiveRecord::Schema[8.1].define(version: 2026_06_23_001313) 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
@@ -473,6 +473,7 @@
t.boolean "completed_day_5", default: false, null: false
t.datetime "created_at", null: false
t.bigint "event_id"
+ t.string "expected_payment_method"
t.text "fee_note"
t.boolean "intends_to_pay", default: false, null: false
t.boolean "invoice_requested", default: false, null: false
@@ -962,6 +963,8 @@
t.string "license_number"
t.string "license_type"
t.string "linked_in_url"
+ t.datetime "mailing_list_consent_at"
+ t.string "mailing_list_consent_source"
t.date "member_since"
t.text "notes"
t.boolean "profile_is_searchable", default: true, null: false
@@ -983,6 +986,7 @@
t.boolean "profile_show_workshop_variations", default: true, null: false
t.boolean "profile_show_workshops", default: true, null: false
t.string "pronouns"
+ t.string "racial_ethnic_identity"
t.text "shoutout_text"
t.string "twitter_url"
t.datetime "updated_at", null: false
diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb
index aff46bee47..44d5be4bc0 100644
--- a/db/seeds/dev/events_management.rb
+++ b/db/seeds/dev/events_management.rb
@@ -103,6 +103,13 @@
.where(field_identifier: %w[impact_description implementation_plan], min_words: [ nil, 0 ])
.update_all(min_words: 250)
+# The real scholarship form heads its first section "Partial Scholarship
+# Application" (see the scholarship screenshots). Idempotent — only renames the
+# default header.
+scholarship_form.form_fields
+ .where(answer_type: :group_header, name: "Scholarship Application")
+ .update_all(name: "Partial Scholarship Application")
+
# Rename the generic section headers FormBuilderService creates to the AWBW
# Facilitator Training wording, and add the subtitles shown on the real form.
# Idempotent: each rename only matches the default name, so a re-seed (or an admin
@@ -126,6 +133,28 @@
registration_form.form_fields.where(answer_type: :group_header, name: "Marketing").destroy_all
registration_form.form_fields.where(field_identifier: "interested_in_more").destroy_all
+# Match the public AWBW form's field set, labels, and order (see the registration
+# screenshots): it labels the primary email "Primary Email", omits the optional
+# Preferred Nickname / Pronouns questions, and lists "What motivated you" before
+# "How did you hear". Idempotent — each clause only matches the default state.
+registration_form.form_fields
+ .where(field_identifier: "primary_email", name: "Email").update_all(name: "Primary Email")
+registration_form.form_fields.where(field_identifier: %w[nickname pronouns]).destroy_all
+
+motivation_field = registration_form.form_fields.find_by(field_identifier: "training_motivation")
+referral_field = registration_form.form_fields.find_by(field_identifier: "referral_source")
+if motivation_field && referral_field && motivation_field.position > referral_field.position
+ motivation_position = motivation_field.position
+ motivation_field.update_column(:position, referral_field.position)
+ referral_field.update_column(:position, motivation_position)
+end
+
+# The real form notes the payment timing under the "Payment Information" heading.
+registration_form.form_fields
+ .where(answer_type: :group_header, name: "Payment Information", subtitle: [ nil, "" ])
+ .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 answer drives the
# resulting registration's ce_credit_requested flag (see
# EventRegistrationServices::PublicRegistration). Seeded straight onto the form
@@ -145,7 +174,7 @@
visibility: :always_ask
)
ce_field = registration_form.form_fields.create!(
- name: "Do you plan to use Continuing Education (CE) hours for this course?",
+ name: "Do you seek Continuing Education (CE) hours for this training?",
answer_type: :single_select_radio,
status: :active,
position: next_position + 1,
@@ -153,8 +182,7 @@
field_identifier: ce_identifier,
section: "continuing_education",
visibility: :always_ask,
- width: :full,
- subtitle: "CE hours are available for select trainings. Let us know and our team will follow up with details."
+ width: :full
)
%w[Yes No].each_with_index do |opt, idx|
ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx }
@@ -169,10 +197,29 @@
field_identifier: "ce_license_number",
section: "continuing_education",
visibility: :always_ask,
- width: :full
+ 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
diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb
index faf6edd11b..29849ef870 100644
--- a/spec/models/person_spec.rb
+++ b/spec/models/person_spec.rb
@@ -432,6 +432,36 @@ def answer(identifier, value)
end
end
end
+
+ describe "#mailing_list_consented=" do
+ it "stamps consent and a source when granted with none on file" do
+ person = build(:person, mailing_list_consent_at: nil)
+
+ person.mailing_list_consented = "1"
+
+ expect(person.mailing_list_consent_at).to be_present
+ expect(person.mailing_list_consent_source).to eq("Admin update")
+ end
+
+ it "preserves the original timestamp and source when re-checked" do
+ original = 1.year.ago
+ person = build(:person, mailing_list_consent_at: original, mailing_list_consent_source: "Registration: X")
+
+ person.mailing_list_consented = "1"
+
+ expect(person.mailing_list_consent_at).to be_within(1.second).of(original)
+ expect(person.mailing_list_consent_source).to eq("Registration: X")
+ end
+
+ it "clears the timestamp and source when withdrawn" do
+ person = build(:person, mailing_list_consent_at: Time.current, mailing_list_consent_source: "Registration: X")
+
+ person.mailing_list_consented = "0"
+
+ expect(person.mailing_list_consent_at).to be_nil
+ expect(person.mailing_list_consent_source).to be_nil
+ end
+ end
end
RSpec.describe Person, "scholarship index helpers" do
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index cbbf84ab23..9d54761661 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -224,6 +224,16 @@
end
end
+ describe "GET /event_registrations/:id/edit" do
+ it "shows the registrant's expected payment method when present" do
+ existing_registration.update!(expected_payment_method: "Check")
+
+ get edit_event_registration_path(existing_registration)
+
+ expect(response.body).to include("Expected payment method").and include("Check")
+ end
+ end
+
describe "PATCH /event_registrations/:id" do
it "can update registration" do
patch event_registration_path(existing_registration),
diff --git a/spec/requests/events/professional_field_identifiers_spec.rb b/spec/requests/events/professional_field_identifiers_spec.rb
index d9581741f7..7848e2a969 100644
--- a/spec/requests/events/professional_field_identifiers_spec.rb
+++ b/spec/requests/events/professional_field_identifiers_spec.rb
@@ -29,19 +29,23 @@
let!(:sector_other) { create(:sector, :published, name: Sector::OTHER_SECTOR_NAME) }
let!(:sector_hidden) { create(:sector, name: "Hidden sector") }
- # Each scheme maps the two sector fields onto a canonical or legacy identifier;
- # the age-group fields have never been renamed, so they stay constant.
+ # Each scheme maps the two sector fields and the additional-age field onto a
+ # canonical or legacy identifier. The primary age field has never been renamed,
+ # so it stays constant; the additional age field also accepts a pluralized
+ # legacy name (`additional_age_groups`) some older forms carry.
{
- "canonical identifiers" => { primary_sector: "primary_sector_single", additional_sector: "additional_sectors" },
- "legacy additional-sector name" => { primary_sector: "primary_sector_single", additional_sector: "primary_sector" },
- "legacy service-area names" => { primary_sector: "primary_service_area_single", additional_sector: "primary_service_area" }
+ "canonical identifiers" => { primary_sector: "primary_sector_single", additional_sector: "additional_sectors" },
+ "legacy additional-sector name" => { primary_sector: "primary_sector_single", additional_sector: "primary_sector" },
+ "legacy service-area names" => { primary_sector: "primary_service_area_single", additional_sector: "primary_service_area" },
+ "pluralized additional-age name" => { primary_sector: "primary_sector_single", additional_sector: "additional_sectors", additional_age: "additional_age_groups" }
}.each do |scheme_name, ids|
context "with #{scheme_name}" do
+ let(:additional_age_identifier) { ids.fetch(:additional_age, "additional_age_group") }
let(:form) { build_professional_form(ids) }
let(:primary_sector_field) { form.form_fields.find_by!(field_identifier: ids[:primary_sector]) }
let(:additional_sector_field) { form.form_fields.find_by!(field_identifier: ids[:additional_sector]) }
let(:primary_age_field) { form.form_fields.find_by!(field_identifier: "primary_age_group") }
- let(:additional_age_field) { form.form_fields.find_by!(field_identifier: "additional_age_group") }
+ let(:additional_age_field) { form.form_fields.find_by!(field_identifier: additional_age_identifier) }
before { EventForm.create!(event: event, form: form, role: "registration") }
@@ -98,7 +102,7 @@
ids[:primary_sector] => sector_education.id.to_s,
ids[:additional_sector] => "#{sector_mh.id}, Other: Equine therapy",
"primary_age_group" => age_adults.id.to_s,
- "additional_age_group" => "#{age_teens.id}, #{age_children.id}"
+ additional_age_identifier => "#{age_teens.id}, #{age_children.id}"
)
end
@@ -150,8 +154,9 @@
# ---- Form construction ----
# Builds a registration form with just the identity + professional sections,
- # then renames the two sector fields onto the scheme's identifiers (the age
- # fields keep their canonical names — they were never renamed).
+ # then renames the two sector fields (and, when the scheme asks, the additional
+ # age field) onto the scheme's identifiers. The primary age field keeps its
+ # canonical name — it was never renamed.
def build_professional_form(ids)
form = FormBuilderService.new(
name: "Reg #{ids[:primary_sector]} / #{ids[:additional_sector]}",
@@ -160,6 +165,7 @@ def build_professional_form(ids)
).call
rename_field(form, "primary_sector_single", ids[:primary_sector])
rename_field(form, "additional_sectors", ids[:additional_sector])
+ rename_field(form, "additional_age_group", ids.fetch(:additional_age, "additional_age_group"))
form
end
diff --git a/spec/requests/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb
index 0ea936a4c1..08ef511845 100644
--- a/spec/requests/people_authorization_spec.rb
+++ b/spec/requests/people_authorization_spec.rb
@@ -90,6 +90,16 @@
it "does not show the Comments section" do
expect(response.body).not_to include("comment_form")
end
+
+ it "shows the racial/ethnic identity" do
+ other_person.update!(racial_ethnic_identity: "Multi-racial")
+ get person_path(other_person)
+ expect(response.body).to include("Multi-racial")
+ end
+
+ it "shows the mailing list consent status" do
+ expect(response.body).to include("No consent on file")
+ end
end
context "as the owner" do
@@ -105,6 +115,16 @@
it "shows the Submitted content section" do
expect(response.body).to include("Submitted content")
end
+
+ it "hides the racial/ethnic identity from the owner" do
+ regular_user.person.update!(racial_ethnic_identity: "Multi-racial")
+ get person_path(regular_user.person)
+ expect(response.body).not_to include("Multi-racial")
+ end
+
+ it "hides the mailing list consent status from the owner" do
+ expect(response.body).not_to include("No consent on file")
+ end
end
end
@@ -146,6 +166,26 @@
expect(response).to redirect_to(person_path(other_person))
expect(other_person.reload.first_name).to eq("Updated")
end
+
+ it "updates the racial/ethnic identity" do
+ patch person_path(other_person), params: { person: { racial_ethnic_identity: "Asian" } }
+ expect(other_person.reload.racial_ethnic_identity).to eq("Asian")
+ end
+
+ it "withdraws mailing list consent when the box is unchecked" do
+ other_person.update!(mailing_list_consent_at: Time.current, mailing_list_consent_source: "Registration: X")
+
+ patch person_path(other_person), params: { person: { mailing_list_consented: "0" } }
+
+ expect(other_person.reload.mailing_list_consent_at).to be_nil
+ expect(other_person.mailing_list_consent_source).to be_nil
+ end
+
+ it "records mailing list consent when the box is checked" do
+ patch person_path(other_person), params: { person: { mailing_list_consented: "1" } }
+
+ expect(other_person.reload.mailing_list_consent_at).to be_present
+ end
end
end
end
diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb
index 95ed227895..2bf600d32e 100644
--- a/spec/requests/scholarships_spec.rb
+++ b/spec/requests/scholarships_spec.rb
@@ -53,6 +53,21 @@
expect(response.body).to include(event_public_registration_path(event, reg: registration.slug))
end
+ it "shows answers submitted on the event's dedicated scholarship form" do
+ scholarship_form = create(:form, name: "Scholarship Application")
+ create(:event_form, event: event, form: scholarship_form, role: "scholarship")
+ field = create(:form_field, form: scholarship_form, section: "scholarship",
+ name: "How much can you contribute?", answer_type: :free_form_input_one_line)
+ submission = create(:form_submission, person: registration.registrant, form: scholarship_form, role: "scholarship")
+ create(:form_answer, form_submission: submission, form_field: field, submitted_answer: "$250")
+
+ get edit_scholarship_path(scholarship)
+
+ expect(response.body).to include("Form submission")
+ expect(response.body).to include("How much can you contribute?")
+ expect(response.body).to include("$250")
+ end
+
it "renders the shared event header: event link, training date, and a profile-linked recipient" do
get edit_scholarship_path(scholarship)
diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb
index 7f9eb316a2..1d4081cd0f 100644
--- a/spec/services/event_registration_services/public_registration_spec.rb
+++ b/spec/services/event_registration_services/public_registration_spec.rb
@@ -51,6 +51,148 @@ def register_with(position:)
end
end
+ describe "structured profile backfill" do
+ it "stores racial/ethnic identity and mailing country on a new registrant" do
+ params = base_form_params(first_name: "Ada", last_name: "Lin", email: "ada@example.com").merge(
+ field_id("racial_ethnic_identity") => "Asian",
+ field_id("mailing_street") => "1 Main St",
+ field_id("mailing_city") => "Oakland",
+ field_id("mailing_state") => "CA",
+ field_id("mailing_zip") => "94601",
+ field_id("mailing_country") => "USA"
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+ person = Person.find_by!(email: "ada@example.com")
+
+ expect(person.racial_ethnic_identity).to eq("Asian")
+ expect(person.addresses.find_by(primary: true).country).to eq("USA")
+ end
+
+ it "overwrites a racial/ethnic identity already on file with the latest answer" do
+ existing = create(:person, first_name: "Ada", last_name: "Lin",
+ email: "ada@example.com", racial_ethnic_identity: "Multi-racial")
+ params = base_form_params(first_name: "Ada", last_name: "Lin", email: "ada@example.com").merge(
+ field_id("racial_ethnic_identity") => "Asian"
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+
+ expect(existing.reload.racial_ethnic_identity).to eq("Asian")
+ end
+
+ it "leaves a racial/ethnic identity on file untouched when the answer is blank" do
+ existing = create(:person, first_name: "Ada", last_name: "Lin",
+ email: "ada@example.com", racial_ethnic_identity: "Multi-racial")
+ params = base_form_params(first_name: "Ada", last_name: "Lin", email: "ada@example.com").merge(
+ field_id("racial_ethnic_identity") => ""
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+
+ expect(existing.reload.racial_ethnic_identity).to eq("Multi-racial")
+ end
+
+ context "with a matched organization" do
+ let!(:organization) { create(:organization, name: "Helping Hands") }
+
+ def register_with_org(extra)
+ params = base_form_params(first_name: "Sam", last_name: "Rowe", email: "sam@example.com").merge(
+ field_id(described_class::ORGANIZATION_NAME_IDENTIFIER) => "Helping Hands"
+ ).merge(extra)
+ described_class.call(event: event, form: form, form_params: params)
+ end
+
+ it "fills website, agency type, and address country when blank" do
+ register_with_org(
+ field_id("agency_website") => "helpinghands.org",
+ field_id("agency_type") => "501c3/nonprofit",
+ field_id("agency_street") => "5 Oak Ave",
+ field_id("agency_city") => "Reno",
+ field_id("agency_state") => "NV",
+ field_id("agency_zip") => "89501",
+ field_id("agency_country") => "USA"
+ )
+ organization.reload
+
+ expect(organization.agency_type).to eq("501c3/nonprofit")
+ expect(organization.website_url).to include("helpinghands.org")
+ expect(organization.addresses.find_by(primary: true).country).to eq("USA")
+ end
+
+ it "overwrites an organization's existing website with the latest answer" do
+ organization.update!(website_url: "https://existing.org")
+
+ register_with_org(field_id("agency_website") => "helpinghands.org")
+
+ expect(organization.reload.website_url).to include("helpinghands.org")
+ end
+ end
+ end
+
+ describe "expected payment method" do
+ it "records the chosen payment method on a new registration" do
+ params = base_form_params(first_name: "Pat", last_name: "Doe", email: "pat@example.com").merge(
+ field_id("payment_method") => "Check"
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+
+ expect(EventRegistration.last.expected_payment_method).to eq("Check")
+ end
+
+ it "updates the expected payment method when an existing registrant re-registers" do
+ person = create(:person, first_name: "Pat", last_name: "Doe", email: "pat@example.com")
+ create(:event_registration, event: event, registrant: person, expected_payment_method: "Check")
+ params = base_form_params(first_name: "Pat", last_name: "Doe", email: "pat@example.com").merge(
+ field_id("payment_method") => "Credit card (now)"
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+
+ expect(event.event_registrations.find_by(registrant: person).expected_payment_method).to eq("Credit card (now)")
+ end
+ end
+
+ describe "mailing list consent" do
+ it "stamps the consent time and source when the registrant opts in" do
+ params = base_form_params(first_name: "Coco", last_name: "Lee", email: "coco@example.com").merge(
+ field_id("communication_consent") => [ "Yes" ]
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+ person = Person.find_by!(email: "coco@example.com")
+
+ expect(person.mailing_list_consent_at).to be_present
+ expect(person.mailing_list_consent_source).to eq("#{event.start_date.to_date.iso8601} #{event.title} registration")
+ end
+
+ it "does not record consent when the box is left unchecked" do
+ params = base_form_params(first_name: "Coco", last_name: "Lee", email: "coco@example.com").merge(
+ field_id("communication_consent") => [ "" ]
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+
+ expect(Person.find_by!(email: "coco@example.com").mailing_list_consent_at).to be_nil
+ end
+
+ it "never re-stamps or clears consent already on file" do
+ original = 1.year.ago
+ create(:person, first_name: "Coco", last_name: "Lee", email: "coco@example.com",
+ mailing_list_consent_at: original, mailing_list_consent_source: "Earlier")
+ params = base_form_params(first_name: "Coco", last_name: "Lee", email: "coco@example.com").merge(
+ field_id("communication_consent") => [ "Yes" ]
+ )
+
+ described_class.call(event: event, form: form, form_params: params)
+ person = Person.find_by!(email: "coco@example.com")
+
+ expect(person.mailing_list_consent_at).to be_within(1.second).of(original)
+ expect(person.mailing_list_consent_source).to eq("Earlier")
+ end
+ end
+
describe "matching an existing registrant by name" do
it "matches a person stored under a nickname when the registrant types their legal first name" do
existing = create(:person, first_name: "Bob", legal_first_name: "Robert",