Status: Complete — shipped in v1.0.80 (July 29, 2026) Effort Class: Medium Prerequisites: Phase 19 (v2 signup flow), Phase 22 (CRM feedback loop), Phase 24 (employments) Surface: Lookup Portal core (
alumnicontroller/views/helpers,AlumniFilterService,Csv::AlumniExporter) + shared models, reading from the signup ecosystem. No writes toCp::orapp/controllers/champions/.
The v2 signup flow (Phase 19) has been collecting location, interest, affinity and
return-visit data for months. None of it reaches the people who need it. Staff open
alumni#show and see a v1-shaped “Alumni Champion Status” card that talks about roles
and a five-step wizard that no longer exists; they search for alumni in a district and
get CRM addresses only, missing every alum who told us they moved; they export a CSV
that has one champion column and no idea what the alum actually said.
Three concrete gaps:
filter_by_district matches LEFT(alumni.zip, 5);
filter_by_affinities matches alumni_affinities only.In v2, handle_who_you_are
requires zip_code, and ChampionSignup#calculate_status returns 5 (:zip_code,
labeled “Completed”) whenever a ZIP is present. Every v2 signup is complete the moment
it is created. champion_signups.completed already means exactly “filled out the v2
basic contact info” — the scope needed no change. What needed changing was the six-option
dropdown built on top of it, where “Show Signups In Progress” can now only ever surface a
v1 legacy straggler.
ChampionSignupCrmLogger only reads alumni_affinities to diff against; nothing writes
signup affinities into it. Signup data reaches the CRM by being logged to crm_data_changes,
exported, and keyed in by Advancement Services — after which it flows back into alumni on
the next BruinQuest sync. So a signup value is pending truth, and the alumni columns
catch up to it later. That is the argument for never overwriting alumni.zip and instead
resolving an effective value at read time.
| Question | Decision |
|---|---|
| Location precedence | Effective location with precedence. Newest active signup’s ZIP wins over alumni.zip for display and district search. alumni.zip is never written. |
| CRM catch-up | Auto-converge on match. When both ZIPs resolve to the same 5 digits, one location renders with no provenance badge. Only divergence shows the dual display. |
| Affinity search | Included by default, badged. filter_by_affinities matches alumni_affinities OR signup affinity_codes. Profile shows one equal-weight list with a source line; search chips use one border and differ by fill. |
| Interest leaning | Replaces Champion Role, keeps the seal art. top_interest_area drives the existing seal/label (area keys already match role keys), plus a 4-bar spectrum on the profile. |
| Filter dropdown | 3 options, with prospect_status split out to its own checkbox. Rendered as a radio inside a grouped filter block (post-review revision). |
| Canonical signup | Most recent by created_at among active signups. One signup drives everything. |
| Profile history | Version + signup date; Returned / Added info stamps. |
| CSV additions | Effective location block; interest leaning; self-reported affinities. |
interest_spectrum returns {} when there are no quiz
answers, so a v2 signup that skipped the quiz has no leaning. Fallback order:
spectrum → explicitly selected final_role → render nothing.location_source in the CSV reads signup only when the signup ZIP is driving
and differs from alumni.zip; otherwise crm. Consistent with auto-converge.interest_scores only. Recomputing a v1 quiz through
ChampionQuizService 50× per search page is not worth the leaning chip. The profile
page, which handles one alum, uses the full computed spectrum.Alumni#current_champion_signup — newest active signup, memoizedAlumni::SignupProfile service (mirrors the Alumni::EducationProfile precedent)SignupProfile::Location value object: city, state, zip, district, source, signup?, labelcrm_affinities / self_reported_affinities / affinity_othertop_interest_area, interest_spectrum, stored_interest_spectrumchampion_signups (buid, created_at) backing the latest-signup subqueryChampionSignup#stored_interest_spectrum (no-compute variant for list views)alumni#showSignupProfile, with provenance on divergenceSource: line
and a detail line (2003 - 2004 • Webmaster); affinity_other is an entry in it,
not a footnote; only BruinQuest rows are editablechampion_signups to avoid N+1Alumni::EFFECTIVE_ZIP_SQL / EFFECTIVE_ZIP5_SQL — correlated subquery for the newest active signup ZIPfilter_by_district matches on effective locationfilter_by_affinities matches either source (subquery OR, never a join)<option> values were affinity.id, not affinity_codesignup_city, signup_state, signup_zip, signup_district, location_sourcetop_interest_area + four area percentagessignup_affinities, signup_affinity_otherdistrict and affinities columns keep their current meaningalumni_filter → a single “Completed Form” checkbox; no_form stays in the scope for legacy bookmarks but is not offered in the UImanual_prospect=1 checkbox, filed under Alumni Champions| Item | Why |
|---|---|
| Duplicate-signup warning on the profile | Not selected in planning; canonical-by-created_at makes acting on stale data unlikely |
signup_version / signup_date / last_return_visit / last_enriched CSV columns |
Not selected; they land on the profile page only |
| Consent + held-CRM-update count on the profile | Not selected; already visible in Signup Admin |
| Free-text city/ZIP search (as opposed to district) | Out of scope — the location search surface is the district autocomplete |
Four changes after the first walkthrough:
Filters grouped; the signup filter is two checkboxes. Five checkboxes and a select in one flat row read as one undifferentiated pile and let the two surfaces blur. Now three labeled fieldsets — Alumni Champions, Alumni Network, Alumni Record — with “Completed Form” and “Manually flagged prospect” under the first.
“Has not completed the form” is not offered. It was briefly built as a three-state
radio on the reasoning that “who in this district have we NOT reached” is the outreach
question the filter exists for. It is not a question this project has reached yet, and
the option returns ~60,000 rows — the wrong thing to put one mis-click away. The
no_form value stays in filter_by_alumni_status so ALUMNI_FILTER_ALIASES has a
target and legacy bookmarks resolve; a view test asserts the form never renders it.
alumni#show. Matches the public landing page and the spectrum
bars, and stops double-framing a badge that already has its label in the artwork.Source: line. In the
search-result chips, provenance moved from the border to the fill — every chip gets
the same solid border, tinted for BruinQuest and white for self-reported. Dashed borders
read as broken rather than provisional, which is obvious once six of them sit in one cell./settings/bruinquest with a 301 from the old path. Ruby
identifiers, tables, columns and the affinaquest_import change-source value keep the
old spelling — renaming those is a migration with no visible benefit, and a half-renamed
identifier is worse than a consistent one.Not changed, deliberately: app/views/cp/policies/privacy.html.erb lists “Heroku
(Salesforce)” in its sub-processor table. That is Heroku’s corporate parent in a legal
disclosure, not CRM branding — and app/views/cp/** is a held surface.
None. Three things outside the original scope were fixed on the way through because the phase touched the same code and would otherwise have shipped on top of them:
<option> values were Affinity#id while
filter_by_affinities matched affinity_code. Submitting without changing the
category filtered a number against a column of codes and returned nothing. Masked
because the affinity-selector Stimulus controller replaces the option list with real
codes as soon as a category is picked — the normal path through the form.shared/_engagement_breakdown interpolated a role color into class names
(bg-#{color}/10, text-<%= color %>), which Tailwind’s JIT never compiles, so that
card had been rendering unstyled since it was written. Rebuilt on LEAN_SURFACE_CLASSES._search_tags had three dead alumni_filter cases (champions, prospectives,
all_champions) that the form has never emitted, so the tag never rendered.| Layer | File | Count |
|---|---|---|
| Resolver | test/services/alumni/signup_profile_test.rb |
23 |
| Scopes + filter | test/models/alumni_test.rb (Phase 25 sections) |
17 |
| Profile, search, filters | test/controllers/alumni_controller_test.rb (Phase 25 section) |
14 |
| Export | test/services/csv/alumni_exporter_test.rb (Phase 25.4 section) |
7 |
Fixtures added: alumni(:relocated_alumni) (CRM Nashville / signup Madison),
champion_signups(:relocated_v2), alumni_affinities(:relocated_sga),
zip_codes(:zip_37212). champion_signups(:completed_linked).answers was changed from
placeholder words to real quiz letters (a/a/c) — ChampionQuizService.normalize_answers
discards anything that is not a valid letter, so the old fixture could never produce a
spectrum and silently modeled a v1 signup that had never taken the quiz.