Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ or symbol. So write `authorize! @setting` in `show` (not `authorize! @setting, t
- `ModelDeduper` β€” Deduplication logic. On a natural-key collision it now merges the losing row into the survivor (recursively, via itself) so the loser's own children move first β€” instead of destroying the loser and cascading them away or aborting on a protective `before_destroy` (e.g. a professional license with CE registrations). It finds reassignable children two ways: the model's own `has_many` declarations, and a scan of the DB's foreign keys. **A polymorphic child carries no foreign key**, so it is reachable only through an explicit `has_many … as:` β€” without one, `#unhandled_references` sees it and the merge is refused outright (this is why `Person`/`Organization` declare `has_many :invoices, as: :invoicee`). A config can also pass `reference_columns:` β€” column names that point at the model in any table without a DB foreign key (the users deduper names `user_id`/`created_by_id`/`updated_by_id`/`welcome_instructions_sent_by_id`). Self-references on the model's own table follow the keeper, except the keeper's own reference to the duplicate, which clears
- `ContinuingEducationDeduper` β€” Collapses duplicate CE registrations left sharing one event registration + license after a merge, moving the losers' allocations (payments) and comments onto the survivor. Keeps the real cost (larger of the two) rather than inventing one to cover the combined payments β€” if they now exceed it, `ContinuingEducationRegistration#over_allocated?` flags it (`ContinuingEducationRegistrationDecorator#overpayment_badge` β†’ "Check payments") for an admin. CE has no DB unique index, so `ModelDeduper` never compares CE rows and just moves them all onto the keeper; a unique index is deliberately avoided because it would force the engine to move CE via validated `update!`, which trips `ContinuingEducationRegistration#license_belongs_to_registrant` mid-merge. Run from the people deduper's `after_merge`
- `AddressNormalizer` β€” Comparison keys for address parts so spellings of one place match: street suffixes/directionals/ordinals (`Street`β†’`st`, `North`β†’`n`, `First`β†’`1st`), unit designators (`Suite`/`Apt`/`Unit`/`#` β†’ `#`), `Saint`/`Mount`/`Fort` in cities, state names β†’ USPS abbreviations, ZIP+4 β†’ five digits, US country spellings (and a blank country in a US state) β†’ `us`, a trailing "County", and an "unknown" type/locality β†’ blank. `.signature(address)` is the whole-address key. Matching only β€” stored addresses keep their spelling. Used by `AddressDeduper`, `OrganizationServices::AddressMatcher`, `OrganizationServices::DuplicateFinder`'s shared-address signal, and `PersonServices::CaptureFromSubmission`'s mailing-address match
- `CountryName` β€” One spelling per country (`US`/`USA`/`United States` β†’ "United States"), via the `countries` gem plus `AddressNormalizer`'s US spellings. `.options` feeds the `*_country` form-field dropdown (US first); `.label` groups country breakdowns in `EventDashboard`, `AttendeesBreakdowns`, `FormResponseAggregator`'s world map, and the attendees country filter. Read-time only β€” stored countries keep their spelling
- `AddressLocality` β€” Works out which `Address::LOCALITIES` bucket an address falls in from its own city/state/country/county, for a writer that has an address but never asked the question (`PersonServices::ImportFromRolodex`, `PersonServices::CaptureFromSubmission`, `OrganizationServices::UpsertAddress`, `ProfileChangeRequests::Apply` β€” all on create only, so a locality a person picked is never overwritten). Reads a named non-US country as **Outside USA**, a non-CA US state as **Outside CA**, a Los Angeles mailing city as **LA City**, the rest of Los Angeles County as **LA County**, and Orange County as **Orange County**. Everything else stays **Unknown**: the Northern/Central/Southern line through California is AWBW's own and nothing records where it runs, and a state no US state answers to could as easily be a typo as a foreign region. Every comparison goes through `AddressNormalizer`. FileMaker records a `Locality` only on a **project**, so the organizations pass (ADR-0008) carries that across rather than deriving it; a person's own address has none
- `AddressDeduper` β€” Merges a person's or organization's addresses that are one place into a single address. `.matching_sets` groups by `AddressNormalizer.signature`, then merges a less complete copy (blank ZIP, street, country, …) into the single fuller address whose every part it shares β€” skipped when it fits more than one; works fullest-first so a chain of partial copies lands on the fullest. Each merge goes through `ModelDeduper#merge`, which repoints the merged address's inbound FKs β€” `contact_methods.address_id`, `invoices.bill_to_address_id`, `affiliations.organization_address_id` β€” onto the survivor. Within a set the survivor is the copy still in use β€” active before inactive, then the primary, then the oldest (`.keeper_order`) β€” since `Address.active` drives bill-to addresses, an org's program location and the geographic breakdowns; blank columns on the survivor (`CARRIED_COLUMNS` β€” the street/ZIP/country/county/district/type/locality, `phone` and the three `la_*` districts; "unknown" type/locality counts as blank) are filled from the merged copies. Then settles a single primary on the set holding the signature captured in the deduper's `merge_keeper` (the survivor's own pre-merge primary address, else the deleted record's), demoting the rest. Runs in the people and organizations dedupers' `after_merge`; `AddressDeduper.merge_duplicates(addressable)` runs it standalone (keeping the owner's current primary) for the **Dedupe addresses** admin page (`AddressesController#dedupe_index`/`#dedupe_perform`)
- `AddressServices::DuplicateFinder` β€” Lists the people and organizations holding addresses that `AddressDeduper.matching_sets` would merge, for the **Dedupe addresses** page. Compares only within one owner β€” two orgs at one street are separate records, not duplicates
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,9 @@ def person_category_ids(category_id)
end

def person_country_ids(country)
Address.active.where(addressable_type: "Person", country: country).select(:addressable_id)
addresses = Address.active.where(addressable_type: "Person")
spellings = addresses.distinct.pluck(:country).select { |spelling| CountryName.label(spelling) == CountryName.label(country) }
addresses.where(country: spellings).select(:addressable_id)
end

# People whose event-registration-linked affiliation points at an organization
Expand Down
8 changes: 8 additions & 0 deletions app/helpers/geography_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,12 @@ def state_select_options(current = nil)

us_states + [ [ current, current ] ]
end

# Country dropdown options, keeping an unrecognized current value selectable so a re-render doesn't drop it.
def country_select_options(current = nil)
label = CountryName.label(current)
return CountryName.options if label.blank? || CountryName.options.include?(label)

CountryName.options + [ label ]
end
end
2 changes: 1 addition & 1 deletion app/services/attendees_breakdowns.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def state_registrant_ids_by_state
end

def country_registrant_ids_by_country
@country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country| country.blank? }) { |row| row[2] }
@country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country| country.blank? }) { |row| CountryName.label(row[2]) }
end

def school_district_registrant_ids_by_district
Expand Down
28 changes: 28 additions & 0 deletions app/services/country_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# One spelling per country, so "US", "USA" and "United States" count as the same place.
module CountryName
UNITED_STATES = "United States"

module_function

# Dropdown choices: the United States first, then every other country A–Z.
def options
@options ||= [ UNITED_STATES ] + (ISO3166::Country.all.map(&:common_name) - [ UNITED_STATES ]).sort
end

# The common name for a typed country or ISO code, or nil when it isn't recognized.
def canonical(value)
text = value.to_s.strip
return if text.blank?
return UNITED_STATES if AddressNormalizer::US_COUNTRY_NAMES.include?(text.downcase.delete("^a-z"))

country = ISO3166::Country.find_country_by_alpha2(text) ||
ISO3166::Country.find_country_by_alpha3(text) ||
ISO3166::Country.find_country_by_any_name(text)
country&.common_name
end

# The common name when recognized, otherwise the value as typed.
def label(value)
canonical(value) || value.to_s.strip.presence
end
end
18 changes: 3 additions & 15 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -870,24 +870,12 @@ def state_registrant_ids
end

def countries
@countries ||= Address
.active
.where(addressable_type: "Person", addressable_id: registrant_ids)
.where.not(country: [ nil, "" ])
.distinct
.pluck(:country)
.sort
@countries ||= country_registrant_ids_by_country.keys.sort
end

# Distinct registrant count per country.
def country_counts
@country_counts ||= Address
.active
.where(addressable_type: "Person", addressable_id: registrant_ids)
.where.not(country: [ nil, "" ])
.group(:country)
.distinct
.count(:addressable_id)
@country_counts ||= country_registrant_ids_by_country.transform_values(&:size)
end

# Registrant ids that have at least one active address with a country on file.
Expand Down Expand Up @@ -925,7 +913,7 @@ def country_registrant_ids_by_country
.where.not(country: [ nil, "" ])
.distinct
.pluck(:country, :addressable_id)
.group_by(&:first)
.group_by { |country, _| CountryName.label(country) }
.transform_values { |rows| rows.map(&:last).uniq }
end

Expand Down
9 changes: 5 additions & 4 deletions app/services/form_response_aggregator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,16 @@ def numeric_value(field, raw)
text.to_i if text.match?(/\A-?\d+\z/)
end

# A choropleth tally of a geographic field: counts per submitted place value,
# left as-is (the map matches state/country names itself; the legend lists the
# exact counts). Multi-value answers split like any other select.
# A choropleth tally of a geographic field: counts per submitted state as-is, or
# per country under one spelling (the map matches state/country names itself; the
# legend lists the exact counts). State answers split like any other select; a
# country answer stays whole since some country names contain commas.
def build_map_report(question, answers, chart)
tally = Hash.new(0)
answered = 0

answers.each do |answer|
values = split_values(answer.submitted_answer)
values = chart == :world_map ? Array(CountryName.label(answer.submitted_answer)) : split_values(answer.submitted_answer)
next if values.empty?

answered += 1
Expand Down
11 changes: 11 additions & 0 deletions app/views/events/public_registrations/_form_field_input.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
<option value="<%= abbr %>" <%= "selected" if value == abbr %>><%= name %> (<%= abbr %>)</option>
<% end %>
</select>
<% elsif field.field_identifier&.end_with?("_country") %>
<% selected_country = CountryName.label(value) %>
<select name="<%= field_name %>"
id="<%= field_id %>"
class="<%= input_classes %>"
<%= "required" if required %>>
<option value="">Select a country</option>
<% country_select_options(value).each do |country| %>
<option value="<%= country %>" <%= "selected" if selected_country == country %>><%= country %></option>
<% end %>
</select>
<% else %>
<%
html_type = if field.date?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: "Pick your country from a list on registration forms"
area: registration
display_status: user_facing
released_on: 2026-10-23
pr_number: 2805
summary: >-
The Country and Organization country questions on registration and other forms
are now a dropdown instead of a text box, so everyone in the US lands under one
"United States."
pro_tips:
- "The United States sits at the top of the list; every other country follows A–Z."
- "Country charts on event dashboards, the attendees page, and form results now count US, USA, and United States as one country β€” including answers typed before the dropdown."
11 changes: 11 additions & 0 deletions spec/helpers/geography_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@
expect(helper.state_select_options("NY").count { |_name, abbr| abbr == "NY" }).to eq(1)
end
end

describe "#country_select_options" do
it "returns the country list when the value is blank or a known country" do
expect(helper.country_select_options).to eq(CountryName.options)
expect(helper.country_select_options("USA")).to eq(CountryName.options)
end

it "appends an unrecognized value so it stays selectable" do
expect(helper.country_select_options("Narnia").last).to eq("Narnia")
end
end
end
11 changes: 11 additions & 0 deletions spec/requests/events/attendees_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,17 @@
expect(response.body).not_to include("Zed Zulu")
end

it "filters a country drill-in across every spelling of that country" do
create(:address, addressable: attendee, country: "USA", inactive: false)
other = create(:person, first_name: "Zed", last_name: "Zulu")
create(:event_registration, event: recent_training, registrant: other, status: "attended")
create(:address, addressable: other, country: "Canada", inactive: false)

get attendees_events_url(country: "United States"), headers: frame_headers
expect(response.body).to include("Ada Lovelace")
expect(response.body).not_to include("Zed Zulu")
end

it "filters by a school-district drill-in (via the org they registered under)" do
org = create(:organization, name: "Compton Schools")
org_address = create(:address, addressable: org, district: "Compton Unified", inactive: false)
Expand Down
9 changes: 9 additions & 0 deletions spec/requests/events/public_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ def post_with_scholarship(scholarship_answer)
expect(response.body).to include("add or update them later")
end

it "renders the country fields as a country dropdown" do
create(:form_field, form: form, field_identifier: "mailing_country", name: "Country", required: false)

get new_event_public_registration_path(event)

expect(response.body).to include("Select a country")
expect(response.body).to include('<option value="United States"')
end

it "renders a structured details panel from known event data when enabled" do
pacific = ActiveSupport::TimeZone["Pacific Time (US & Canada)"]
start_date = 2.weeks.from_now.in_time_zone(pacific).change(hour: 9)
Expand Down
38 changes: 38 additions & 0 deletions spec/services/country_name_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
require "rails_helper"

RSpec.describe CountryName do
describe ".canonical" do
it "reads the many ways registrants type the US as one country" do
%w[US usa U.S.A. America].push("United States of America", "united states").each do |typed|
expect(described_class.canonical(typed)).to eq("United States")
end
end

it "resolves ISO codes and other names to the common name" do
expect(described_class.canonical("CAN")).to eq("Canada")
expect(described_class.canonical("UK")).to eq("United Kingdom")
expect(described_class.canonical(" mexico ")).to eq("Mexico")
end

it "is nil for a blank or unrecognized value" do
expect(described_class.canonical(nil)).to be_nil
expect(described_class.canonical("")).to be_nil
expect(described_class.canonical("Narnia")).to be_nil
end
end

describe ".label" do
it "keeps an unrecognized value as typed" do
expect(described_class.label(" Narnia ")).to eq("Narnia")
end
end

describe ".options" do
it "lists the United States first, then every other country alphabetically" do
expect(described_class.options.first).to eq("United States")
expect(described_class.options.drop(1)).to eq(described_class.options.drop(1).sort)
expect(described_class.options).to include("Canada", "Mexico")
expect(described_class.options.count("United States")).to eq(1)
end
end
end
13 changes: 13 additions & 0 deletions spec/services/event_dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,19 @@
it "maps each country to its registrant ids" do
expect(dashboard.country_registrant_ids_by_country.values.flatten).to contain_exactly(person1.id, person2.id)
end

it "counts the different ways registrants typed the same country as one country" do
typed_usa = create(:person)
typed_us = create(:person)
[ [ typed_usa, "USA" ], [ typed_us, "US" ] ].each do |person, country|
create(:event_registration, event: event, registrant: person, status: "registered")
create(:address, addressable: person, country: country)
end

expect(dashboard.countries.count("United States")).to eq(1)
expect(dashboard.countries).not_to include("USA", "US")
expect(dashboard.country_registrant_ids_by_country["United States"]).to include(typed_usa.id, typed_us.id)
end
end

describe "school districts" do
Expand Down
9 changes: 9 additions & 0 deletions spec/services/form_response_aggregator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ def answer(submission, field, value)
expect(report.kind).to eq(:map)
expect(report.chart).to eq(:world_map)
end

it "tallies the different spellings of a country as one country" do
field = create(:form_field, form: form, name: "Country", field_identifier: "mailing_country")
[ "USA", "U.S.", "United States", "Virgin Islands, British" ].each { |typed| answer(create(:form_submission, form: form), field, typed) }

report = described_class.new(form).field_reports.first

expect(report.rows).to eq([ [ "United States", 3 ], [ "Virgin Islands, British", 1 ] ])
end
end

describe "free-text questions" do
Expand Down
4 changes: 2 additions & 2 deletions spec/system/public_registration_form_submission_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ def fill_full_registration
fill_pr_text reg_field("mailing_city"), with: "Los Angeles"
select_pr reg_field("mailing_state"), "California (CA)"
fill_pr_text reg_field("mailing_zip"), with: "90001"
fill_pr_text reg_field("mailing_country"), with: "United States"
select_pr reg_field("mailing_country"), "United States"
fill_pr_text reg_field("phone"), with: "555-123-4567"
choose_pr_radio reg_field("phone_type"), "Work"

Expand All @@ -407,7 +407,7 @@ def fill_full_registration
fill_pr_text reg_field("organization_city"), with: "Pasadena"
select_pr reg_field("organization_state"), "California (CA)"
fill_pr_text reg_field("organization_zip"), with: "91101"
fill_pr_text reg_field("organization_country"), with: "United States"
select_pr reg_field("organization_country"), "United States"

select_pr reg_field("primary_sector"), "Education"
check_pr_box reg_field("additional_sectors"), sector_mental_health.id.to_s
Expand Down
Loading