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",