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.
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.
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.
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.
zip_code stays U.S.-only; international data gets its own columnsNew columns on both champion_signups and opportunity_responses:
country — ISO 3166-1 alpha-2 ("CA", "GB"). NULL means United States, so the
existing ~250 signups need no backfill.postal_code — the international postal code, as typed (stripped, upcased for display only).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.
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.
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.
location_label / primary_location / display_location return “City, Country” for
international rows.@top_districts like any other.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).
US, plus territories that use U.S. ZIPs (PR, GU, VI, AS, MP,
UM). Those alums use the ZIP path, and the existing “Pacific & Islands” region covers
them. APO/FPO military addresses are U.S. ZIPs as well.ChampionSignup, OpportunityResponse, the shared location
partial, Csv::ChampionSignupExporter and Csv::OpportunityResponseExporter.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:
zip_code, street, state; assign the continent district.country, postal_code, city; re-run the ZIP lookup.Location edits already record through the existing section-update path. No new
ChampionSignupEvent type is added.
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.
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.
Country, concern, districts, status) ✅config/countries.yml + Country PORO (Decision F)InternationalLocation concern (§3)ChampionSignup: status rung uses location_complete?; district assignment branches on
international?; relocation covers U.S.↔international switches (Decision G); display
methods (Decision E)OpportunityResponse: validation is location_complete? instead of zip_code presence;
district assignment and display_location follow the same rules; to_signup_attributes
carries country/city/postal_codeWhat Was Implemented (31.1):
config/countries.yml; Country.ensure_international_districts! is the
single home of the region/district seed (called by the migration and by tests). It never
renames a district staff already renamed.InternationalLocation owns normalization ("US" and blank both mean the U.S.),
country validation, location_complete?, international_location_label, the continent
district, and Decision G’s reconcile step (before_validation, so the status rung sees the
reconciled fields).ChampionSignup#assign_district_from_zip is skipped for international rows, so clearing the
ZIP on a move abroad can’t trigger relocating? and wipe the newly typed city.test/models/country_test.rb, test/models/champion_signup_international_location_test.rb,
plus an international section in opportunity_response_test.rb. Sabotage-checked: reverting
location_complete? to zip_code.present? fails 6 of them.Spec Deviations (31.1), all refinements:
district_reach_label returns nil for international rows. “Part of the Europe district”
would restate the continent beside a line that already names the country.to_signup_attributes carries city only for international responses. On a U.S. response,
city is ZIP-derived, and copying it would make it read as typed by the alum.signups_controller permits country, city,
postal_codeopportunities_controller permits them, and
prefill_contact_block copies themlocation_complete? (copy: “where you live”,
not “ZIP”); the “We have you in…” line follows Decision EWhat Was Implemented (31.2):
app/views/shared/_location_fields.html.erb + location_mode_controller.js. The inactive
panel’s inputs are disabled, and blank hidden zip_code/country fields sit outside the
panels, so whichever kind is in use is what submits. Staff preview passes disabled, which
locks the inputs; the panels still swap.has_zip → has_location (location_complete?). The banner asks international alums
for their city; the empty-state copy mentions the city path. The “· 37212” suffix shows the
postal code abroad and is omitted when there is none.test/controllers/signup/international_location_test.rb (14).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
Csv::ChampionSignupExporter and Csv::OpportunityResponseExporter: country and
postal_code columns; full address includes the country nameChampionSignupCrmLogger: "country" => nil (alumni has no country column), and
"postal_code" => "zip"ChampionSignupMerger: location merges as a unit. Pick the record with the best
complete location and copy zip/country/city/postal/state/street together, so a merge can
never pair a Nashville ZIP with a Berlin cityAlumni::SignupProfile (Lookup alumni page) displays international locationsOpportunityResponseDigest displays international locationsWhat Was Implemented (31.3):
signup_location_display checks international first, so the all-signups list
doesn’t print just “Europe”. New signup_admin/_international_badge partial on the all
list (both layouts), the signup detail page, and the opportunity-response list and detail.country (name) and postal_code columns after zip_code. The signup export’s
district reads the continent district abroad. Removed the exporter’s full_address, which
was computed and never written (dead code)."postal_code" => "zip" and "country" => nil, reported by name via
VALUE_READERS. Added country to the CRM change export’s Address group and to
CRM_REPORTABLE_FIELDS / needs_identity_match (a guard test caught that drift).LOCATION_FIELDS copied from one source (location_source: a complete location
first, newest first).city/state only when the same save
didn’t write new values to them, so a merge that brings a U.S. row’s typed city along keeps
it. Form submissions never send city on the U.S. panel, so form behavior is unchanged.test/services/international_location_data_flow_test.rb (11),
test/controllers/signup_admin/international_location_test.rb (5), plus 4 conversion tests in
signup/opportunities_controller_test.rb. Full suite: 6,133 runs, 0 failures.Spec Deviations (31.3):
full_address turned out to be
unused, so it was removed instead. The country column covers the need.CA → North America, DE → Europe, JP → Asia & Middle East; a U.S. ZIP is
unchanged. A German postal code 10115 on an international row does not resolve
to New York.US, PR, GU, VI, AS, MP, UM; every entry’s
district_code exists after migration.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.
alumni has no country column and the Contacts
import doesn’t carry one, so international signups can’t be prefilled from CRM, and the
CRM logger reports country with a blank old value. → BACKLOGCp::Champion profile wizard). Held.docs/features/CHAMPION_SIGNUP_SYSTEM.md — location sectionCHANGELOG.mddocs/planning/qa/PHASE_31_LAUNCH_GUIDE.md