alumni_lookup

Phase 31 — International Addresses

Status: ✅ Complete — October 6, 2026 (31.1, 31.2, 31.3). Model handoff: the user chose to continue on the current model (“let’s get going”), October 6, 2026. Surface: Signup ecosystem — signup/ (signup form, hub, opportunity form), signup_admin/ (staff display), and the shared layer (Country, a model concern, app/views/shared/, opportunities/fields/_contact_block). Nothing touches cp/ or alumni_network/. See §7 for the one shared-data note: new regions/districts rows.

Completion Summary

Alumni outside the U.S. can now finish signing up and answer opportunities. Behind “Not living in the U.S.?” they give a Country and City (plus an optional Postal code), land in one of six continent districts, and reach “Completed” on the same terms as a ZIP. Staff see “Berlin, Germany” with an International badge everywhere a location shows, and the data flows through both CSV exports, the CRM change log and the duplicate merger.

Key files: config/countries.yml · app/models/country.rb · app/models/concerns/international_location.rb · app/views/shared/_location_fields.html.erb · app/javascript/controllers/location_mode_controller.js · app/views/signup_admin/_international_badge.html.erb · two migrations (§4).

Bug fixed on the way (pre-existing since 28.3): converting an opportunity response into a signup lost its phone and ZIP.

Tests: 6,133 runs, 0 failures at wrap. New: country_test.rb, champion_signup_international_location_test.rb, signup/international_location_test.rb, signup_admin/international_location_test.rb, international_location_data_flow_test.rb, plus additions to opportunity_response_test.rb and signup/opportunities_controller_test.rb. Also verified end to end in headless Chrome against the dev server (31.2).

Deferred (BACKLOG → International Addresses): country on the CRM alumni record; per-country postal-code validation. Not touched: the held portal’s own free-text international location.

Deploy: two manual migrations, no rake tasks. See the launch guide.

1. The Question This Phase Answers

Can an alum who lives outside the United States finish signing up?

Today they can’t. The “Where you are now” ZIP field is required and labeled “ZIP code”. It also sets inputmode: "numeric", so phones show a number pad and UK or Canadian postcodes can’t be typed. If someone does force one in, the district lookup strips it to digits and matches it against U.S. ZIPs: Berlin’s 10115 resolves to New York. A completed signup also requires zip_code.present? (ChampionSignup#calculate_status), so an international alum can never reach Completed, and the hub shows them the missing-ZIP banner forever. The public opportunity form has the same field, and there it is NOT NULL.

About 98% of people filling in these forms live in the U.S. So the U.S. path stays exactly as it is, and the international path sits behind one link.

2. Decisions

Decision A — Progressive disclosure, one shared UX on both forms

Below the ZIP field, a quiet link reads “Not living in the U.S.?” Clicking it swaps the ZIP field for:

Field Required Notes
Country ✅ <select>, alphabetical, U.S. and U.S.-ZIP territories omitted (Decision F)
City ✅ Free text
Postal code Optional Free text, letters allowed, no inputmode, autocomplete="postal-code"

A “Back to U.S. ZIP” link swaps back. If a record is already international (editing from the hub, or a returning opportunity respondent), the form opens on the international fields.

The same partial and Stimulus controller serve both the signup “Where you are now” section and the opportunity contact block (app/views/opportunities/fields/_contact_block.html.erb). The partial lives in app/views/shared/, and the controller is shared JS.

Decision B — zip_code stays U.S.-only; international data gets its own columns

New columns on both champion_signups and opportunity_responses:

City reuses the existing city column.

Why not put the postal code in zip_code: about a dozen consumers call ZipCode.lookup(zip_code...), including the exporter, assign_district_from_zip, zip_code_record and the CRM logger. Each would need a country guard, and missing one silently files a Berlin alum in New York. A separate column makes “zip_code is a U.S. ZIP” an invariant enforced by the schema, so no guard can be forgotten.

Decision C — Completion = Country + City

The location section is complete when the alum has a U.S. ZIP, or a non-U.S. country and a city. The postal code is collected when people have one, but not required: many countries have none, and requiring it needs a per-country “uses postal codes” table to stay fair.

No international postal-code lookup and no province field. City + country is enough to know where someone lives. Resolving an international postal code to a province would need geodata for ~240 countries, which is the “complex lookup system” this phase avoids. The state column stays blank for international rows.

Decision D — Continent districts under a new “International” region

One new region, International (code: "international"), with six districts, created up front:

District code Name Covers
intl-north-america North America Canada, Mexico, Central America, Caribbean
intl-south-america South America  
intl-europe Europe incl. UK, Russia
intl-asia Asia & Middle East  
intl-africa Africa  
intl-oceania Oceania Australia, NZ, Pacific nations

Names are staff-editable like any district. Per-country grouping is always derivable from the country column, so no per-country districts are created.

Decision E — Display: “City, Country”

Decision F — Country list is a committed YAML file plus a shared PORO, not a gem

config/countries.yml holds code, name and continent for each country. app/models/country.rb is an unnamespaced PORO (Country.all, Country.find("CA"), #name, #continent, #district_code).

Decision G — Switching between U.S. and international counts as relocating

Today ChampionSignup#relocating? clears street/city/state when one ZIP replaces a different ZIP. Switching between the U.S. and international paths, in either direction, is the same event and gets the same treatment:

Decision H — No new activity events

Location edits already record through the existing section-update path. No new ChampionSignupEvent type is added.

3. Facts and Their Single Home

Each fact this phase decides lives in exactly one method. A second copy is a defect.

Fact Home
Is this record international? InternationalLocation#international? (country.present?)
Is the location section complete? InternationalLocation#location_complete? — used by the status rung, the hub banner, and OpportunityResponse validation
Which district does a country map to? Country#district_code → District.find_by(code:)
What does “where they are” read as? InternationalLocation#international_location_label — called from each model’s existing display method
Which countries may be picked? config/countries.yml

InternationalLocation is a concern in app/models/concerns/, part of the shared layer, included by ChampionSignup and OpportunityResponse.

4. Schema

add_column :champion_signups,      :country,     :string, limit: 2
add_column :champion_signups,      :postal_code, :string
add_column :opportunity_responses, :country,     :string, limit: 2
add_column :opportunity_responses, :postal_code, :string
change_column_null :opportunity_responses, :zip_code, true

A second migration creates the International region and its six districts with idempotent find_or_create_by!(code:). It runs in every environment, so no rake task is needed.

5. Sub-Phases

31.1 — Foundation (schema, Country, concern, districts, status) ✅

What Was Implemented (31.1):

Spec Deviations (31.1), all refinements:

31.2 — Forms and hub ✅

What Was Implemented (31.2):

Observed, pre-existing, not fixed: the opportunity → signup conversion pre-fills only step 1, which has had no location field since 28.3. So a converted response’s ZIP (and now its country and city) is carried into ChampionSignup.new but never shown or saved. → BACKLOG

31.3 — Staff side and data flow ✅

What Was Implemented (31.3):

Spec Deviations (31.3):

6. Acceptance Tests

7. Shared-Data Note

regions and districts are also read by the held portal (Cp::Champion#district, Cp::CommunityDetectionService). This phase adds rows, not code, in those tables, and edits no held path. Portal members get their district only from a U.S. ZIP, so they can never land in an international district, and no portal community is created for one.

Portal prior art (read, not touched): Cp::Champion#international_location is a free-text “City, Country” string from cp/profile_wizard/_international_location, with no district. Structured country + city renders to that exact string, so reconciling the two front doors later is a lossless conversion in this direction.

8. Explicitly Deferred

9. Documentation Updates