From 6fa81ee7b95b9ef9e30b1e5491216cd76e283dfe Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 15:20:10 -0400 Subject: [PATCH 1/7] Accept "additional_age_groups" as a legacy age-group field identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some production registration forms carry the pluralized identifier "additional_age_groups" on the additional age-group field. The code only ever recognized the canonical singular "additional_age_group", so on those forms the field rendered with no options, rejected submissions, and never tagged age groups — failing silently end to end. Lift the hardcoded age-group identifiers into PRIMARY/ADDITIONAL identifier- list constants (mirroring the sector legacy-coverage pattern) and accept the plural alias everywhere they resolve: dynamic options, submission validation, tagging, profile display, and the form-submission show page. Co-Authored-By: Claude Opus 4.8 --- app/helpers/event_helper.rb | 2 +- app/models/form_field.rb | 27 +++++++++++++------ app/models/person.rb | 2 +- .../public_registration.rb | 10 +++---- .../professional_field_identifiers_spec.rb | 24 ++++++++++------- 5 files changed, 41 insertions(+), 24 deletions(-) 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..10b249a761 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -230,7 +230,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..021832b852 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -273,10 +273,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 +292,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 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 From e6d366cd56a0129da581a0da530af9e52bf1d67e Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 16:33:24 -0400 Subject: [PATCH 2/7] Persist registration form answers as structured profile data Several registration fields were saved only as form_answers, never mapped onto their existing model columns: mailing/agency country, organization website and type. And there was no structured home for racial/ethnic identity at all. - Add people.racial_ethnic_identity, shown on the person profile and editable on the edit form, both gated to admins (allowed_to?(:manage?, Person) for display; the edit page is already admin-only). - In PublicRegistration, map racial_ethnic_identity, mailing_country/ agency_country, agency_website, and agency_type onto Person/Organization/ Address. A non-blank answer overwrites the value on file (latest registration wins); the prior value is preserved by AhoyTrackable's after_update audit event. A blank answer never clobbers existing data. Co-Authored-By: Claude Opus 4.8 --- app/controllers/people_controller.rb | 1 + .../public_registration.rb | 35 +++++++- app/views/people/_form.html.erb | 7 ++ app/views/people/show.html.erb | 7 ++ ...22_add_racial_ethnic_identity_to_people.rb | 9 +++ db/schema.rb | 3 +- spec/requests/people_authorization_spec.rb | 17 ++++ .../public_registration_spec.rb | 79 +++++++++++++++++++ 8 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260622202122_add_racial_ethnic_identity_to_people.rb diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index e698aa54eb..1ca362d1a0 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -394,6 +394,7 @@ def person_params :license_number, :license_type, :credentials, + :racial_ethnic_identity, :bio, :shoutout_text, :notes, :display_name_preference, :pronouns, diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 021832b852..8c1e58b18c 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) @@ -166,6 +170,29 @@ 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")) + 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 +209,7 @@ def create_mailing_address(person) primary: true, inactive: false ) + apply_value(existing, :country, field_value("mailing_country")) return existing end @@ -192,6 +220,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 +285,7 @@ def create_agency_address(organization) primary: true, inactive: false ) + apply_value(existing, :country, field_value("agency_country")) return existing end @@ -266,6 +296,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 diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index f3f31acacb..c1f1b6ab78 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -371,6 +371,13 @@ 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" } %> +
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index d8fdc59d3b..140c5b0066 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -165,6 +165,13 @@ <%= 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 %> 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/schema.rb b/db/schema.rb index b33af524e5..984280863e 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_22_202122) 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 @@ -983,6 +983,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/spec/requests/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index 0ea936a4c1..8a98c2375a 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -90,6 +90,12 @@ 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 end context "as the owner" do @@ -105,6 +111,12 @@ 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 end end @@ -146,6 +158,11 @@ 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 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 7f9eb316a2..97f89855e7 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -51,6 +51,85 @@ 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 "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", From 036568d73f2c03a4e0462117d50f2d6e68b8560c Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 16:37:43 -0400 Subject: [PATCH 3/7] Align seeded forms with the public AWBW forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the dev registration/scholarship forms to the real form screenshots: reword the CE question ("Do you seek CE hours for this training?"), add the license-number disclaimer, label the primary email "Primary Email", drop the optional nickname/pronouns questions, order "What motivated you" before "How did you hear", note payment timing under Payment Information, and head the scholarship section "Partial Scholarship Application". Every clause is idempotent and only matches the prior default, so re-seeds and admin edits are left alone — including updates that bring an already-seeded CE block up to the new wording. Co-Authored-By: Claude Opus 4.8 --- db/seeds/dev/events_management.rb | 55 ++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) 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 From ee47f5d24153b715ceb097b33e49b6b9673f84f2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 20:11:27 -0400 Subject: [PATCH 4/7] Surface scholarship answers from the dedicated scholarship form The scholarship edit view read the registration form's submission and filtered for "scholarship" section fields, so it only worked when the questions were embedded in the registration form. The app actually links a separate role: "scholarship" form whose answers are saved as their own submission, so the edit view showed "No scholarship questions were answered" even when they were. load_scholarship_submission now prefers the event's dedicated scholarship form (and its role: "scholarship" submission), falling back to the embedded-section registration form for older events. Co-Authored-By: Claude Opus 4.8 --- app/controllers/scholarships_controller.rb | 28 ++++++++++++++++--- .../scholarships/_form_submission.html.erb | 6 ++-- spec/requests/scholarships_spec.rb | 15 ++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) 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/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/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) From 5c0258e41e30f35da1633fb2f873934f38e06c6e Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 20:17:11 -0400 Subject: [PATCH 5/7] Store expected payment method and mailing-list consent structurally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more registration answers gain structured homes: - expected_payment_method (string) on event_registrations records the registrant's stated payment intent (Credit card now/later, Check) — the one payment signal not otherwise visible for the non-Stripe paths. Set on registration and overwritten on re-registration (latest intent wins); shown read-only in the admin registration form. Bulk payment keeps it as a form answer only, since it creates no EventRegistration until allocation. - mailing_list_consent_at / mailing_list_consent_source on people record email consent. Opt-in only: an affirmative answer stamps the time and source when none is on file; we never re-stamp or clear it from registration (withdrawal is a separate action). Shown admin-gated on the profile. Co-Authored-By: Claude Opus 4.8 --- .../public_registration.rb | 23 ++++++- app/views/event_registrations/_form.html.erb | 6 ++ app/views/people/show.html.erb | 13 ++++ ...d_payment_method_to_event_registrations.rb | 9 +++ ...1313_add_mailing_list_consent_to_people.rb | 11 ++++ db/schema.rb | 5 +- spec/requests/event_registrations_spec.rb | 10 +++ spec/requests/people_authorization_spec.rb | 8 +++ .../public_registration_spec.rb | 63 +++++++++++++++++++ 9 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260623001312_add_expected_payment_method_to_event_registrations.rb create mode 100644 db/migrate/20260623001313_add_mailing_list_consent_to_people.rb diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 8c1e58b18c..8126c81806 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -64,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) @@ -178,6 +179,25 @@ def find_matching_person(last_name:, email:) # 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: "Registration: #{@event.title}" + ) + end + + def mailing_list_consent_given? + Array(field_value("communication_consent")).any? { |value| value.to_s.strip.present? } end def sync_organization_profile(organization) @@ -342,7 +362,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/show.html.erb b/app/views/people/show.html.erb index 140c5b0066..c82d6cdeb8 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -172,6 +172,19 @@

<%= @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/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 984280863e..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_202122) 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 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/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index 8a98c2375a..7dd2538a91 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -96,6 +96,10 @@ 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 @@ -117,6 +121,10 @@ 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 diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 97f89855e7..1a000cb341 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -130,6 +130,69 @@ def register_with_org(extra) 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 include(event.title) + 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", From 4b4e3a433fd5f6ed4422ecdbc9c4f15eb9f5202f Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 20:34:24 -0400 Subject: [PATCH 6/7] Add mailing-list consent date to source and an admin withdrawal control - Include the event start date in the consent source ("Registration: (<date>)") so consent is traceable when many trainings share a title. - Add a "Consented to mailing list" checkbox to the admin person form via a virtual mailing_list_consented attribute: unchecking withdraws (clears the timestamp and source), checking grants when none is on file (stamped "Admin update"), and re-checking leaves an existing consent untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- app/controllers/people_controller.rb | 1 + app/models/person.rb | 22 ++++++++++++++ .../public_registration.rb | 10 ++++++- app/views/people/_form.html.erb | 10 +++++++ spec/models/person_spec.rb | 30 +++++++++++++++++++ spec/requests/people_authorization_spec.rb | 15 ++++++++++ .../public_registration_spec.rb | 1 + 7 files changed, 88 insertions(+), 1 deletion(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 1ca362d1a0..e46c5ff6af 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -395,6 +395,7 @@ def person_params :license_type, :credentials, :racial_ethnic_identity, + :mailing_list_consented, :bio, :shoutout_text, :notes, :display_name_preference, :pronouns, diff --git a/app/models/person.rb b/app/models/person.rb index 10b249a761..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" diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 8126c81806..28f7ffc40b 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -192,10 +192,18 @@ def record_mailing_list_consent(person) person.update!( mailing_list_consent_at: Time.current, - mailing_list_consent_source: "Registration: #{@event.title}" + mailing_list_consent_source: mailing_list_consent_source ) end + # Identify the event by title *and* start date — many trainings share a title, + # so the date is what makes the consent source traceable to one event. + def mailing_list_consent_source + label = "Registration: #{@event.title}" + label += " (#{@event.start_date.to_date.to_fs(:long)})" if @event.start_date.present? + label + end + def mailing_list_consent_given? Array(field_value("communication_consent")).any? { |value| value.to_s.strip.present? } end diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index c1f1b6ab78..55a94a55f8 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -378,6 +378,16 @@ input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> </div> + + <div class="w-full sm:w-64"> + <%= 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" } %> + </div> </div> </div> </div> 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/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index 7dd2538a91..08ef511845 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -171,6 +171,21 @@ 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/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 1a000cb341..b9a5e4f4ca 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -165,6 +165,7 @@ def register_with_org(extra) expect(person.mailing_list_consent_at).to be_present expect(person.mailing_list_consent_source).to include(event.title) + expect(person.mailing_list_consent_source).to include(event.start_date.to_date.to_fs(:long)) end it "does not record consent when the box is left unchecked" do From dd98c321a265611ef10f2c4d5240cad795692aaa Mon Sep 17 00:00:00 2001 From: maebeale <maebeale@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:37:04 -0400 Subject: [PATCH 7/7] Format consent source as "<date> <title> registration" Lead with the ISO date so the registration source reads "2026-06-23 Facilitator Training registration". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../event_registration_services/public_registration.rb | 9 ++++----- .../public_registration_spec.rb | 3 +-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 28f7ffc40b..aabe5f87f2 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -196,12 +196,11 @@ def record_mailing_list_consent(person) ) end - # Identify the event by title *and* start date — many trainings share a title, - # so the date is what makes the consent source traceable to one event. + # 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 - label = "Registration: #{@event.title}" - label += " (#{@event.start_date.to_date.to_fs(:long)})" if @event.start_date.present? - label + [ @event.start_date&.to_date&.iso8601, "#{@event.title} registration" ].compact.join(" ") end def mailing_list_consent_given? diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index b9a5e4f4ca..1d4081cd0f 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -164,8 +164,7 @@ def register_with_org(extra) person = Person.find_by!(email: "coco@example.com") expect(person.mailing_list_consent_at).to be_present - expect(person.mailing_list_consent_source).to include(event.title) - expect(person.mailing_list_consent_source).to include(event.start_date.to_date.to_fs(:long)) + 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