Status: 🔄 In Progress (19.1–19.6 shipped; 19.7–19.8 planned)
Priority: High
Spec: docs/planning/champion-flow/SPEC.md
Domain: alumnichampions.com
Date Started: 2026-06-08
Rebuilds the public Champion signup flow that was retired in Phase 17.3. The new flow lives at alumnichampions.com/sign-up as a server-side multi-step wizard under a new public/ controller namespace, cleanly separated from both the staff (champions/) and portal (cp/) namespaces.
Key additions vs. v1:
champion_signup_v2) for staff analyticsbelmontalum.com/sign-up cannot access the flow| Sub-phase | Name | Status |
|---|---|---|
| 19.1 | Foundation: Schema, Routes, Controller Shell | ✅ Complete |
| 19.2 | Personal Info Flow (Steps 1–3) | ✅ Complete |
| 19.3 | Quiz Flow + Role Selection + Light Interests | ✅ Complete |
| 19.4 | Full Affinities, Submission + Mailer | ✅ Complete |
| 19.5 | Confirmation Page + Staff Queue Integration | ✅ Complete |
| 19.6 | UX Revision Pass (post-feedback) | ✅ Complete |
| 19.7 | Congrats/Profile Page + Flow Reorder | ✅ Complete |
| 19.8 | Interests Redesign (Spectrum Quiz) | ✅ Complete |
| 19.9 | Signup Admin Dashboard, District Translation, Spam Guard | ✅ Complete (v1.0.67) |
Shipped: 2026-06-08
champion_signups (college_code, major_code, industry, job_title, affinity_codes jsonb, affinity_other, source + index)Public::ChampionSignupsController < PublicController with who_you_are create logic + all other step stubsalumnichampions.com/ root → signup landing (overrides Devise anonymous root)belmontalum.com/sign-up from matchingconfig/initializers/champion_signup.rb restoredalumnichampions.com added to allowed hosts (test + production envs)Phase prep (sub-phase interview, planning summary, model handoff) was skipped for 19.1 due to a process error. All subsequent sub-phases must go through the full prep checkpoint before implementation begins.
Shipped: 2026-06-08
_who_you_are.html.erb): name, optional previous/maiden name (toggle-reveal), graduation year(s) free text, email, required ZIP, optional phone. Creates the ChampionSignup record and stores session[:champion_signup_id]._belmont_experience.html.erb): a single free-text field (belmont_background) for college(s)/major(s), intentionally minimizing friction for legacy naming and multi-degree paths._where_you_are.html.erb): profession capture with employment status and conditional fields (company/job/industry) using signup_employment_status_controller.js.affinities and role stub pages were added so QA can follow You → Belmont → Interests → Job → Role before full 19.3/19.4 implementation lands.handle_who_you_are / handle_belmont_experience / handle_affinities (stub) / handle_where_you_are / handle_role (stub), with session guards across signup-dependent steps.belmont_background:text, company:string._error_summary.html.erb for validation feedback.belmont_background) instead of normalized college/major selectors./profile/wizard/affinities) → 19.4.form_with url: (no model) + fields_for :scope renders field names as [scope][field] (leading bracket), which does not round-trip through params.require(:scope) from the rendered form. Fixed by using form_with model: signup, scope: :champion_signup.
Shipped: 2026-06-08
steps/_role.html.erb): relational context + three clear paths on one surface (Take the quiz, I already know, Skip for now). Role cards are hidden initially and revealed only when “I already know” is clicked; uses inner role icons (not full seals). After a quiz, the recommended role is highlighted (“Recommended for you” + “Confirm this role”).steps/_quiz_question.html.erb, reused for question1–question7): one question per page, shuffled options, answers persisted to champion_signups.answers (jsonb), back navigation supported.steps/_quiz_results.html.erb): recommended role via ChampionQuizService, persists result_role, shows seal + narrative + blend breakdown, then routes back to the role hub to confirm.steps/_affinities.html.erb): replaced the stub with interest-category checkboxes (belonging_categories) + optional note (belonging_note).show.html.erb): celebrates the chosen role with seal, title, and description.handle_question, handle_role (supports role confirm or skip-for-now completion), handle_affinities (light persistence); shared quiz partial via @partial_step.signup_token; controller can recover the signup when session state is absent so role selection no longer bounces to landing.ChampionQuizService, ChampionRoleService, ChampionSignupsHelper, role_seal_svg/role_icon_svg.who_you_are → belmont_experience → affinities (Interests) → where_you_are (Job) → role hub → [optional: question1–7 → quiz_results → role] → confirmation
Full affinity browse/search (affinity_codes, affinity_other) with expanded belonging_note prompt, submission mailer, staff-queue source tagging.
lifestage_interest or belonging_categories in this flow.belonging_note textarea asking where the alum felt the strongest sense of belonging.source as champion_signup_v2 from step 1 creation.deliver_later.Cp:: activity tracking for this unauthenticated flow.Cp:: model/event integration remains out of scope.affinity_codes/affinity_other plus an expanded belonging_note; do not collect lifestage_interest or belonging_categories.deliver_later) for welcome/admin notifications.steps/_affinities.html.erb): rewritten as full affinity browse/search reusing the cp-affinity-selector Stimulus controller. Searchable, category-filterable list (Geographic + Post Graduation categories excluded). Affinity codes submit as top-level affinity_codes[] hidden inputs and persist to champion_signups.affinity_codes. Adds an affinity_other free-text field and a belonging_note reflection (“Where did you feel the most belonging at Belmont?”). No lifestage_interest / belonging_categories capture.Public::ChampionSignupsController): source='champion_signup_v2' set on step 1 creation; load_affinities_data loads @all_affinities/@affinity_categories/@selected_affinity_codes; handle_affinities persists codes + other + note; finalize_signup_and_redirect + deliver_signup_emails send mail on the final role step for both confirm and skip paths. Removed the INTEREST_CATEGORIES constant.ChampionSignupMailer, restored): welcome_email (role copy from ChampionRoleService, generic fallback) and admin_notification (full field summary + source + BUID status + staff link). Per-instance default_url_options (Alumni Champions domain) plus lookup_url_options (Lookup Portal) for the staff link. Four templates under app/views/champion_signup_mailer/.champion_signups#index): v2 source badge in the table and a Source filter dropdown; filtered_signups honors params[:source].ChampionSignupMailerTest, and a staff source-filter test. No migration needed (columns shipped in 19.1). Full suite 4,514 runs / 0 failures.app/views/public/champion_signups/show.html.erb): role branch upgraded with full activity list + explicit next-steps messaging; no-role branch now has a guarded follow-up CTA.Public::ChampionSignupsController): final role completion redirect now includes a signed signup_token; confirmation page only shows “Choose your role now” when the token is valid and belongs to the current signup.ChampionSignupsController#index + filtered_signups and index view): added role status, BUID link status, affinity data presence, recency buckets, contactability, and specific affinity filtering.app/views/champion_signups/index.html.erb): refreshed queue notice for mixed legacy/v2 data and improved interests column to surface affinity-based data in addition to legacy interests.app/views/champion_signups/show.html.erb): dedicated section for source, college_code, major_code, industry, job_title, affinity_other, and affinity_codes.test/controllers/public/champion_signups_controller_test.rb (secure CTA/tokenized confirmation path + branch rendering)test/controllers/champion_signups_controller_test.rb (expanded staff filters + v2 details rendering)Shipped: 2026-06-09
_belmont_experience.html.erb): Changed from “Your Belmont story” to “Your Area of Study” — the original felt open-ended; this is more literal and sets clearer expectations._quiz_results.html.erb): Replaced the two-hop “Continue to role confirmation → click ‘I already know’ → confirm” with two inline actions: Confirm this role (POSTs the recommended role directly to handle_role, finalizes signup) and Select a different role (links to the role hub). Removed the now-stale “What happens next?” box._role.html.erb): Updated banner copy when arriving from quiz. Instead of “Click ‘I already know’ to review and confirm it,” now reads “Your quiz recommended X. Choose a different role below.” — matches the new flow intent._where_you_are.html.erb, migration, controller, Stimulus): Added a current_season_note (text) field that appears only when “I’m not currently working” is selected. Label: “What are you up to these days?” Persisted via new column; cleared automatically when the user selects employed or seeking. selected_employment_status detection updated to recognize current_season_note presence as the not_working state on revisit.add_column :champion_signups, :current_season_note, :textCompletion currently feels gated behind the full flow (Belmont Experience → Affinities → Where You Are → Role), but ChampionSignup#calculate_status already marks a signup “completed” (status 5) the moment email + graduation_year + zip_code are present — which happens at step 1 (who_you_are). The flow/UI/email timing just hasn’t caught up to that. This sub-phase moves the experienced completion point up to match the data model’s completion point, and turns the post-completion page into a returnable profile rather than a one-time confirmation screen.
belmont_experience from the flow entirely — the college/major free-text step is removed. We already have this from the alumni record in most cases (re-surfaced in 19.7’s profile page as a confidence note, not re-collected here).who_you_are → Congrats/Profile page → affinities → where_you_are → interests (renamed from role; full redesign in 19.8).show.html.erb becomes the single destination for: the immediate post-step-1 “You’re in!” page, the hub linking to the 3 remaining optional sections, and the page a returning user lands on via their link. Copy adapts based on whether any optional sections are filled in yet — no separate confirmation page.calculate_status already treats step-1 completion (zip_code present) as status 5; this sub-phase only changes when the UI/email act on that fact, not the data model.signed_id(purpose:, expires_in:) pattern with a new purpose key (:profile_access) and a short expiry (~72h).ChampionSignup by it, and sends a fresh signed link if found. Response is generic regardless of match (“if we found your info, we sent a link”) to avoid leaking who has/hasn’t signed up.show) requires either the just-signed-up session OR a valid :profile_access token. Accessing it without either (expired link, guessed/sequential ID) does not expose PII — it redirects to the landing “email me a link” prompt. Closes the pre-existing enumeration hole where /sign-up/:id was viewable by raw ID.buid is already linked (high-confidence match, i.e. staff has verified). Will not show on a brand-new signup’s first visit (no BUID yet); will appear on return visits after staff verification completes.role and existing quiz/role partials in 19.7; the hub labels that section “Interests.” The role→interests rename happens in 19.8 alongside the content redesign, to avoid churning the quiz routing/tests twice.who_you_are) — already true at the data layer; this sub-phase makes the experience match it.:profile_access, ~72h), regenerated per request. No permanent link.:profile_access token; otherwise redirect to the landing “email me a link” prompt.nudge_sent_at column). Logged to BACKLOG.None for 19.7. Token mechanism is stateless (signed_id); no new columns.
Shipped: 2026-06-25
belmont_experience step entirely (partial deleted; belmont_background column retained for legacy records + staff export/email). New order: who_you_are → profile hub → optional affinities / where_you_are / role(interests), each returning to the hub. Multi-stage progress bar removed (_progress_bar.html.erb deleted); step 1 is the single required step, the hub shows optional-section progress.handle_who_you_are now sends the welcome and admin emails immediately on first creation (guarded by a creating flag so later contact-info edits don’t re-send), then redirects to the tokenized hub. Removed the end-of-flow finalize_signup_and_redirect/email send.show.html.erb rewritten): celebratory header with adaptive first-visit vs. return copy, “more info = better opportunities” framing, a 3-section progress bar, three optional-section cards (done/Add states), contact-info edit, and a BUID-gated education-record note (“We have you down as a [College] grad”).ChampionSignup#profile_token / .find_by_profile_token, purpose :profile_access, 72h). Replaced the old :public_signup_step token usage throughout the step partials. Hub show is now access-gated (session or valid token); unauthorized/expired/guessed-ID access redirects to the landing “email me a link” prompt instead of exposing PII — closes the prior /sign-up/:id enumeration hole.POST /profile-link → request_link action + landing form. Looks up an active signup by email (case-insensitive), sends ChampionSignupMailer.profile_link_email if found, and always responds generically (?link_sent=1) to prevent email enumeration.welcome_email now includes a tokenized profile link (HTML + text); new profile_link_email action + templates; shared profile_url_for helper.affinities_provided? / professional_info_provided? / interests_provided? / optional_sections_completed_count (hub progress) and find_active_by_email (link request).public/champion_signups_controller_test for the hub-and-spoke flow + access gating + request-link; added mailer tests (welcome link, profile_link_email); added model tests (token round-trip/tamper/deleted, section predicates, email lookup). Full suite: 4,532 runs, 12,183 assertions, 0 failures, 0 errors, 3 skips.nudge_sent_at column).role → interests step rename (happens in 19.8 with the content redesign).Ready to implement? Confirmed — proceed once the model handoff to the implementation model occurs.
The current quiz crowns a single role (“You are a Connection Advisor”), but ChampionQuizService.role_counts already computes a full per-area breakdown from the same 7 answers — it’s just not displayed that way. Moving to a spectrum display is largely a results-screen change, reusing existing quiz content and scoring logic. Staff also need the same spectrum visibility (and the ability to filter on it), which requires persisting the breakdown rather than only computing it for the public results screen.
add_column :champion_signups, :interest_scores, :jsonb, default: {} — persists the per-area breakdown, saved at the same moment result_role is saved today (when the quiz’s last question is answered). (See Interview Addendum below: stored as percentages, not raw counts.)answers but no interest_scores populated yet.result_role keeps being persisted as the top-scoring area, for backward compatibility with existing staff filters/badges and v1 records. selected_role stops being set going forward (nothing left to manually pick) but the column/validation is left untouched.interest_scores rather than computing on the fly.add_column :champion_signups, :interest_scores, :jsonb, default: {}answers-only records.Re-confirmed scope at the start of implementation planning. Four decisions refine/override the points above:
interest_scores stores { role_key => percentage }, each area’s share of the answered questions, rounded to whole numbers. Not raw 0–7 counts. Public bars and the staff threshold both read percentages, so the staff queue filter is “area ≥ N%” (not “≥ N count”). ChampionQuizService already exposes role_counts; add a sibling method (e.g. interest_spectrum(answers)) that returns the percentage map — do not alter role_counts / generate_results / calculate_primary_role, which the Cp portal still depends on.Cp::ChampionRolesHelper keeps the single-winner experience. 19.8 touches only the public signup results screen + staff admin. New service methods must be additive.area_name on ChampionRoleService::ROLES):
Displayed as “Making Connections (Connection Advisor)”. Copy can be tuned later.
result_role still persisted as the top-scoring area (existing first-of-tied tie-break retained) for backward compatibility. Backfill computes percentages from answers; records with no answers are left {}.
Ready to implement? Confirmed — proceed once the model handoff to the implementation model occurs.
add_column :champion_signups, :interest_scores, :jsonb, default: {}.ChampionQuizService.interest_spectrum(answers) (additive): returns { role_key => Integer percentage }, each area’s share of the answered questions, rounded. role_counts / generate_results / calculate_primary_role left intact for the Cp portal.ChampionRoleService: added area_name: to each role (Making Connections / Sharing Stories / Bringing People Together / Supporting Causes) plus area_name and area_label (“Plain Name (Role Title)”) methods. Helper wrappers champion_area_name / champion_area_label.Public::ChampionSignupsController): the final quiz answer (handle_question) now persists interest_scores + result_role (top-scoring area). Removed the POST role handler and direct pick-a-role path entirely. The role step is now the quiz-only Interests landing (_role.html.erb): shows the interest spectrum + “Retake the quiz” when answered, or prompts to take the quiz / skip when not. quiz_results redesigned as the spectrum reveal with a single “Save and continue” to the hub. New shared partial steps/_interest_spectrum.html.erb (percentage bars, dual-named, literal Tailwind color map).ChampionSignupsController): index filter interest_area + interest_min (“area ≥ N%”, v2 only) via jsonb COALESCE((interest_scores ->> ?)::int, 0) >= ?; new “Interest Area” / “Min Interest %” filter inputs. show renders an Interest Spectrum breakdown card when interest_scores is present.champion_signups:backfill_interest_scores[dry_run] (lib/tasks/champion_signup_interest_scores.rake) — populates interest_scores for v2 signups with letter-based quiz answers; fills result_role when blank; skips legacy/word-answer records.signup_role_options_controller.js (was the “I already know” reveal on the old role hub).interest_spectrum (4 cases) + area_name/area_label; public controller (quiz persists scores + redirects, Interests landing prompt vs. spectrum, no POST role handler); staff controller (interest area/min filter, show breakdown). Full suite 4,536 runs / 0 failures / 0 errors.quiz_results kept as a distinct surface (not collapsed into the hub). After the final question the user sees a celebratory spectrum reveal (“Where your interests lean”) with “Save and continue” → hub; the Interests landing reuses the same _interest_spectrum partial when revisited. Lower flow churn, matches the “graph if answered / prompt if not” behavior.role internally (param only, not user-visible) to avoid route/hub churn; the partial and copy are fully redesigned.interest_scores and the spectrum are v2-only. Legacy records store answers as {"q0" => "word"} (not letters), so they are excluded from scoring, backfill, and the staff breakdown/filter.Follow-up tweaks from a UX/QA pass after the first build:
_interest_spectrum updated; area_label retained for the staff filter dropdown.role_icon_svg instead of the full circular seal, for a lighter look.ChampionSignupsController#show now computes the spectrum from answers when interest_scores is blank (any letter-valued quiz, keyed "0".."6" or legacy "q0".."q6" — scoring only counts values), so pre-backfill quiz-takers still render the full breakdown. The staff card now reuses the shared _interest_spectrum partial plus a “Leans most toward” header for parity with the public reveal.link_to … method: :patch + data-confirm, which silently did a GET after @rails/ujs was removed (the PATCH-only route then 404’d). Switched to data: { turbo_method: :patch, turbo_confirm: … }.Follow-up welcome email rewrite, Champions branding pass, and a dev-environment bug fix found in the same session:
alumnichampions.com: favicons + the PWA manifest (manifest-champions.json — existed but was never linked from the layout) now use the -champions icon set; meta-image-champions.png is the OG/Twitter image on the landing flow and congrats hub; the site header uses the white Champions lockup. Hero/congrats-hub logo placement is mid-revision, owned directly by the user as of this writing — not yet on a Champions-specific lockup.Procfile.dev), but 7 places across Cp::/ChampionSignupMailer and Cp::Champion hardcoded protocol: "http" for dev.* hosts — those URLs connected but hung instead of erroring, so the breakage was invisible until someone actually loaded one. All switched to https. A separate bug in the same sweep: two staff-notification mailers pointed at local.test, which has no DNS record at all — repointed to dev.alumnilookup.com:3000.public/ outside of any commit.docs/CHANGELOG.md [Unreleased] for the full file list and test counts.