Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 24 additions & 4 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/event_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 19 additions & 8 deletions app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Building DYNAMIC_FIELD_CATEGORY_TYPES from the identifier constants means dynamic option rendering, submission validation, and the option-source badge all pick up additional_age_groups for free β€” these consumers key off this hash, so they needed no further change.

# 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
Expand Down
24 changes: 23 additions & 1 deletion app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 67 additions & 8 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Overwrite (not fill-when-blank) is deliberate: the latest registration is the freshest source of truth, and because every model includes AhoyTrackable, this update! logs an Ahoy::Event capturing the priorβ†’new value β€” so no history is lost. A blank answer is a no-op, and an unchanged value records no event.

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
Expand All @@ -182,6 +236,7 @@ def create_mailing_address(person)
primary: true,
inactive: false
)
apply_value(existing, :country, field_value("mailing_country"))
return existing
end

Expand All @@ -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
Expand Down Expand Up @@ -256,6 +312,7 @@ def create_agency_address(organization)
primary: true,
inactive: false
)
apply_value(existing, :country, field_value("agency_country"))
return existing
end

Expand All @@ -266,17 +323,18 @@ 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
)
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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Collecting across the identifier list (like sectors already did) means a form with either the canonical or the legacy plural name tags correctly; renamed the helper from collect_sector_ids to collect_ids since it now serves both.


if primary_sector_ids.any? || additional_sector_ids.any?
person.tag_sectors(primary_ids: primary_sector_ids, additional_ids: additional_sector_ids)
Expand All @@ -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

Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions app/views/event_registrations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@
<% end %>
</div>
<% end %>
<% if f.object.expected_payment_method.present? %>
<div class="mt-3 text-xs text-gray-500">
Expected payment method:
<span class="font-medium text-gray-700"><%= f.object.expected_payment_method %></span>
</div>
<% end %>
<%= render "payment_history", event_registration: f.object %>

<% if allowed_to?(:index?, with: NotificationPolicy) %>
Expand Down
17 changes: 17 additions & 0 deletions app/views/people/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,23 @@
input_html: { class: "w-full" },
wrapper_html: { class: "w-full" } %>
</div>

<div class="w-full sm:w-64">
<%= f.input :racial_ethnic_identity,
label: "Racial/ethnic identity",
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>
Expand Down
20 changes: 20 additions & 0 deletions app/views/people/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@
<%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups %>
</div>
<% end %>
<!-- Racial/ethnic identity (admin only) -->
<% if allowed_to?(:manage?, Person) && @person.racial_ethnic_identity.present? %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Display is gated with allowed_to?(:manage?, Person) so an owner viewing their own profile (show? = admin? || owner?) does not see this; editing is already admin-only via PersonPolicy#edit?.

<div class="md:col-span-2 p-3 admin-only bg-blue-100 rounded-lg">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Racial/ethnic identity</h2>
<p class="text-gray-700"><%= @person.racial_ethnic_identity %></p>
</div>
<% end %>
<!-- Mailing list consent (admin only) -->
<% if allowed_to?(:manage?, Person) %>
<div class="md:col-span-2 p-3 admin-only bg-blue-100 rounded-lg">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Mailing list consent</h2>
<% if @person.mailing_list_consent_at.present? %>
<p class="text-gray-700">
Consented <%= @person.mailing_list_consent_at.to_date.to_fs(:long) %><%= " β€” #{@person.mailing_list_consent_source}" if @person.mailing_list_consent_source.present? %>
</p>
<% else %>
<p class="text-gray-500 italic">No consent on file.</p>
<% end %>
</div>
<% end %>
</div>
</div>
<!-- Bio -->
Expand Down
6 changes: 3 additions & 3 deletions app/views/scholarships/_form_submission.html.erb
Original file line number Diff line number Diff line change
@@ -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? } %>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions db/migrate/20260623001313_add_mailing_list_consent_to_people.rb
Original file line number Diff line number Diff line change
@@ -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
Loading