Domain: alumnichampions.com
Repo: alumni_lookup (see Architecture Decision below)
Phase: 19 (sub-phases 19.1–19.8)
Status: In Progress — 19.1–19.6 complete, 19.7–19.8 planned
Date: 2026-06-08 (updated 2026-06-24)
| Sub-phase | Name | Key Deliverable | Status |
|---|---|---|---|
| 19.1 | Foundation: Schema, Routes, Controller Shell | Landing page live, DB migrated | ✅ Complete |
| 19.2 | Personal Info Flow (Steps 1–3) | Record creation through belmont_experience + where_you_are | ✅ Complete |
| 19.3 | Quiz Flow + Role Selection + Light Interests | Quiz playable, role hub (pick or quiz), light interests | ✅ Complete |
| 19.4 | Full Affinities, Submission + Mailer | Full affinity capture + email delivery live | ✅ Complete |
| 19.5 | Confirmation Page + Staff Queue Integration | UX complete, staff can filter/review v2 records | ✅ Complete |
| 19.6 | UX Revision Pass | Post-feedback polish (headings, quiz confirm flow, not-working note) | ✅ Complete |
| 19.7 | Congrats/Profile Page + Flow Reorder | Completion moves to step 1; returnable on-demand profile page | 📋 Planned |
| 19.8 | Interests Redesign (Spectrum Quiz) | Single-role quiz → 4-area spectrum, public + staff | 📋 Planned |
Shipped: 2026-06-08
db/migrate/20260608185511_add_v2_fields_to_champion_signups.rb): 7 new columns on champion_signups — college_code, major_code, industry, job_title, affinity_codes (jsonb, default []), affinity_other, source + index on sourceapp/models/champion_signup.rb): Added comment documenting that v2 fields are optional at the model level; step-level validation happens in the controllerconfig/routes.rb): Nested constraints ->(req) { req.host.include?('alumnichampions') } block inside the outer domain constraint — ensures belmontalum.com/sign-up cannot accidentally match the signup routes. alumnichampions.com/ root override fires before Devise’s cp_unauthenticated_root so the signup landing is the first thing visitors see. Three explicit routes: GET /sign-up → #new, POST /sign-up → #create, GET /sign-up/:id → #showconfig/initializers/champion_signup.rb): Restored; sets Rails.application.config.champion_signup_admin_emails from CHAMPION_SIGNUP_ADMIN_EMAILS env var, defaulting to alumni@belmont.eduapp/controllers/public/champion_signups_controller.rb): Public::ChampionSignupsController < PublicController; who_you_are step creates a record and stores session[:champion_signup_id]; all other steps stub-redirect to landing; show action renders confirmation placeholdernew.html.erb container, _header.html.erb (Belmont Blue branded), _progress_bar.html.erb (4 stages), steps/_landing.html.erb (hero + 4 role cards with seals + CTAs), steps/_who_you_are.html.erb (stub), show.html.erb (placeholder)alumnichampions.com added to config/environments/test.rb and config/environments/production.rb (staging.alumnichampions.com + www.alumnichampions.com also in production)test/controllers/public/champion_signups_controller_test.rb): 15 tests covering routing, landing render, step validation, record creation, session storage, new column writability, confirmation pageapp/controllers/public/champion_signups_controller.rbapp/views/public/champion_signups/new.html.erbapp/views/public/champion_signups/show.html.erbapp/views/public/champion_signups/_header.html.erbapp/views/public/champion_signups/_progress_bar.html.erbapp/views/public/champion_signups/steps/_landing.html.erbapp/views/public/champion_signups/steps/_who_you_are.html.erb (stub)config/initializers/champion_signup.rbdb/migrate/20260608185511_add_v2_fields_to_champion_signups.rbtest/controllers/public/champion_signups_controller_test.rbconfig/routes.rb — new routes + nested domain constraintconfig/environments/test.rb — alumnichampions.com added to allowed hostsconfig/environments/production.rb — alumnichampions.com + staging/www variants addedapp/views/layouts/public.html.erb — header partial path updatedapp/models/champion_signup.rb — v2 field comment addedapp/controllers/alumni_network/roadmap_controller.rb — Phase 19 entries addedresource :champion_signup (singular) was replaced with three explicit routes because singular REST routes don’t map cleanly to the step-based URL structure (GET /sign-up → #new is not the Rails default for singular resources).belmontalum.com/sign-up would have matched without it. This was not in the original Phase 19.1 spec but is clearly correct behavior.Goal: Scaffold is live. alumnichampions.com/sign-up renders the landing page (read-only, no submission yet). All other steps redirect to landing.
champion_signups (college_code, major_code, industry, job_title, affinity_codes jsonb, affinity_other, source + index)attr_accessor + validations for new fieldsresource :champion_signup under module: 'public' inside domain constraint; step param routes all stepsconfig/initializers/champion_signup.rb restoredPublic::ChampionSignupsController with all step branches stubbed (redirect to landing)new.html.erb container, _header.html.erb, _progress_bar.html.erb, _landing.html.erbGoal: Working signup through the first three data steps with a clean, low-friction data-capture UX.
_who_you_are.html.erb: first/last name, maiden name toggle, graduation year(s) free-text, email, required ZIP, optional phone_belmont_experience.html.erb: one free-text field (belmont_background) for college(s)/major(s)_where_you_are.html.erb: profession snapshot with employment status choices + company/job title/industry capturewho_you_are (record creation + session store), belmont_experience, affinities (stub), where_you_are, role (stub)What was implemented:
belmont_background) to support legacy/renamed schools and multi-degree histories.employed: company + job title + industryseeking: industry onlynot_working: clears company/job/industrywho_you_are → belmont_experience → affinities (stub) → where_you_are → role (stub)._error_summary.Spec deviations (approved):
graduation_year) to support alumni who want to provide multiple years.belmont_background) instead of normalized college/major selectors.affinities and role stub steps were added to support the intended navigation order during phased delivery.Deferred to later sub-phases: full affinities implementation, full quiz/results flow, and full role-selection interaction.
Shipped: 2026-06-08
Goal: Make the Role stage fully interactive — users can pick a role directly or take the 7-question quiz — and replace the Interests stub with a real (light) capture step.
What was implemented:
steps/_role.html.erb): the first interactive page after the profession step. Adds pastoral/relational orientation copy and three explicit paths: Take the quiz, I already know, and Skip for now. Role cards are hidden by default and revealed only after clicking “I already know.” After a quiz, the recommended role is highlighted with a “Recommended for you” badge and a “Confirm this role” button.steps/_quiz_question.html.erb, reused for question1–question7): one question per page, options shuffled per render, answer persisted to champion_signups.answers (jsonb). Back navigation returns to the previous question (or the role hub from Q1).steps/_quiz_results.html.erb): computes the recommended role via ChampionQuizService.generate_results, persists result_role, shows the role seal, narrative, and a full blend breakdown, then routes to the role hub to confirm (“show results, then role”).steps/_affinities.html.erb): replaced the stub with interest-category checkboxes (belonging_categories) + an optional free-text note (belonging_note). All fields optional.show.html.erb): now celebrates the chosen role with its seal, title, and description (reuses role_seal_svg + role helpers).handle_question, handle_role (supports role confirm or skip-for-now completion path), handle_affinities (light persistence), plus load_quiz_question_data / load_quiz_results_data / load_role_data. Shared quiz partial wired via @partial_step.signup_token; controller falls back to token-based record lookup when session state is missing so role submissions still land on confirmation.ChampionQuizService, ChampionRoleService, ChampionSignupsHelper, role_seal_svg/role_icon_svg.Spec deviations (approved during planning):
belonging_note (no lifestage or belonging-category capture).Deferred to 19.4: full affinity browse/search UI (affinity_codes, affinity_other) with expanded belonging_note, submission mailer, and staff-queue source tagging finalization.
Shipped: 2026-06-09
app/views/public/champion_signups/steps/_affinities.html.erb) using the existing cp-affinity-selector Stimulus controller.affinity_codes (top-level affinity_codes[] hidden inputs), affinity_other, and expanded belonging_note reflection copy.Geographic and Post Graduation categories (matches profile wizard behavior).lifestage_interest / belonging_categories capture in this flow (approved scope change).source='champion_signup_v2' set at Step 1 creation in Public::ChampionSignupsController.ChampionSignupMailer.welcome_email and ChampionSignupMailer.admin_notification via deliver_later for both role-confirm and skip-for-now paths.ChampionSignupMailer with four templates (welcome_email + admin_notification, HTML + text) and per-instance URL option overrides.ChampionSignupsController#index and app/views/champion_signups/index.html.erb.ChampionSignupMailerTestlifestage_interest or belonging_categories capture.Goal: Form completable end-to-end. Submissions appear in staff queue. Emails send.
_affinities.html.erb: affinity browse/search for student groups/experiences, affinity_other, and expanded belonging_notesource = "champion_signup_v2" at step 1 creation; send emails on final role completion; clear session; redirect to showChampionSignupMailer (delegate to ChampionRoleService, not inline hash); instance-level URL options for alumnichampions.comGoal: Experience complete for user and staff.
show.html.erb: role branch (seal + title + activities + what-happens-next); no-role branch (general + soft CTA)source filter in filtered_signupscollege_code, major_code, industry, job_title, affinity_codes, affinity_otherShipped: 2026-06-09
signup_token is present for that specific signup.Shipped: 2026-06-09
_where_you_are: added current_season_note (text), shown only when “I’m not currently working” is selected; cleared automatically on employed/seeking.add_column :champion_signups, :current_season_note, :text.(See docs/planning/phases/phase-19/README.md for full 19.6 detail.)
ChampionSignup#calculate_status already marks a signup “completed” (status 5) the moment email + graduation_year + zip_code are present — true since step 1 (who_you_are). The flow/UI/email timing hasn’t matched that yet. This sub-phase moves the experienced completion point up to step 1 and turns the post-completion page into a returnable profile.
belmont_experience from the flow entirely (we already have this from the alumni record).who_you_are → Congrats/Profile page → affinities → where_you_are → interests (renamed from role; redesigned 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 what’s already filled in.calculate_status already treats step-1 completion as status 5; only the UI/email timing changes.signed_id(purpose:, expires_in:) pattern (already used for the role follow-up CTA) with a new purpose key and a short expiry (~72h).buid is already linked (high-confidence match) — won’t show on a brand-new signup, will appear after staff verification.None anticipated.
Hub-and-spoke flow live: belmont_experience dropped, completion moved to step 1 (welcome + admin emails fire there), unified Congrats/Profile hub at /sign-up/:id (session- or :profile_access-token-gated, with optional-section progress + BUID-gated education note), “email me a link” return flow (POST /profile-link + profile_link_email, generic anti-enumeration response), and landing reframed to “Ways Champions show up” interest areas. No migration. Full suite 4,532 runs / 0 failures. Decisions (2026-06-25): both emails at step 1; nudge email deferred to BACKLOG; role→interests rename deferred to 19.8. Full detail in docs/planning/phases/phase-19/README.md.
ChampionQuizService.role_counts already computes a per-area breakdown from the same 7 answers used to crown a single role today. Moving to a spectrum display is largely a results-screen change. Staff need the same spectrum visibility plus the ability to filter on it, which requires persisting the breakdown.
add_column :champion_signups, :interest_scores, :jsonb, default: {} — persists the same breakdown role_counts already computes, saved at the same moment result_role is saved today.answers but no interest_scores.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 but the column/validation is left untouched.add_column :champion_signups, :interest_scores, :jsonb, default: {}answers-only records.alumni_lookupWith belmontalum.com’s future uncertain (possible mobile API + app pivot), a potential BruinQuest API integration on the horizon, and the Champion Signup relaunch now in scope — does this go in a new repo, or stay in alumni_lookup?
alumni_lookupRationale: A standalone repo for Champion Signup would immediately need to duplicate or re-fetch:
ChampionSignup model, ChampionRoleService, ChampionQuizService, and ChampionSignupsHelper (all intact in this repo)affinities table and its browse/search UI (already built for the profile wizard)AlumniMatcherpublic layoutDomain routing already handles alumnichampions.com via the existing constraint block in config/routes.rb (line 9–12: req.host.include?('alumnichampions')). No infrastructure change needed.
This is worth a deliberate conversation before Phase 18 wraps. The current trajectory:
alumni_lookup is a monolith serving three distinct surfaces: the internal Lookup Portal, the external Alumni Portal (belmontalum.com), and now alumnichampions.comcp/ and champions/ namespaces are already showing strain from the “one repo, many audiences” patternRecommendation for now: Stay monolithic. The division cost (shared auth, shared models, inter-service calls) is not yet worth the isolation benefit. When the mobile API spec matures, revisit the split with a clear service boundary proposal. Do NOT split preemptively.
The current namespace situation is a historical artifact of the build sequence and is confusing:
| Namespace | Location | Audience | What it does |
|---|---|---|---|
champions/ (controllers) |
app/controllers/alumni_network/ |
Staff (alumnilookup.com/alumni_network/) |
Staff management of Alumni Network |
cp/ (controllers + models) |
app/controllers/cp/, app/models/cp/ |
Alumni (belmontalum.com) |
Champion Portal — all alumni-facing features |
champion_signups (no ns) |
app/controllers/champion_signups_controller.rb |
Staff | Reviewing/managing signup records |
ChampionSignup (model) |
app/models/champion_signup.rb |
Both | The signup record |
The confusing part: The old public signup controller was named AlumniNetwork::ChampionSignupsController — placed in the staff namespace (champions/) but actually serving the public. This is what we are rebuilding.
v2.0 naming decision: The new public signup controller will live in a new public/ namespace to make the audience explicit:
Public::ChampionSignupsController < PublicControllerapp/controllers/public/champion_signups_controller.rbapp/views/public/champion_signups/This cleanly separates public-facing flows from both the staff (champions/) and portal (cp/) namespaces. No changes to existing controllers are required.
Note on future cleanup: The cp/ vs champions/ split is a deeper problem that should be addressed in a dedicated refactor phase (not in this feature). Document the intent: cp/ → rename to portal/ or alumni_portal/; champions/ (staff) → rename to program/. That work is out of scope here.
Phase 17.3 (“Legacy Signup Flow Retirement”) removed the original public Champion signup flow. What was deleted:
| File | Description |
|---|---|
app/controllers/alumni_network/champion_signups_controller.rb |
Public 7-step wizard controller (158 lines), unauthenticated, inherited from PublicController |
app/mailers/champion_signup_mailer.rb |
2 mailer actions: welcome_email, admin_notification; private champion_role_data helper |
app/views/alumni_network/champion_signups/champion_signup_mailer/ |
4 mailer templates: welcome_email.html.erb, welcome_email.text.erb, admin_notification.html.erb, admin_notification.text.erb |
app/views/alumni_network/champion_signups/steps/ |
9 partials: _welcome, _info, _question, _results, _role, _details, _interests, _score_bar, _score_chart |
app/views/alumni_network/champion_signups/new.html.erb |
Container view for all steps |
app/views/alumni_network/champion_signups/show.html.erb |
Confirmation page (public-facing, post-submit) |
app/views/alumni_network/champion_signups/_header.html.erb |
Public header partial |
app/views/alumni_network/champion_signups/_progress_bar.html.erb |
Step progress indicator |
app/views/alumni_network/champion_signups/_logo_primary.svg.erb |
Logo SVG (now deleted; _seal and _icon were preserved) |
app/views/alumni_network/champion_signups/_alumni_champions.svg.erb |
Full-text SVG logo |
config/initializers/champion_signup.rb |
Set Rails.application.config.champion_signup_admin_emails |
lib/tasks/champion_signup_emails.rake |
Rake task for resending mailer emails |
| Tests: 3 files | Public flow tests, mailer tests, mailer previews |
What was preserved:
ChampionSignup model (fully intact with all columns and methods)ChampionSignupsController — the staff controller at app/controllers/champion_signups_controller.rb (no namespace; requires auth)app/views/champion_signups/ — staff views (index, show)ChampionSignupsHelper (delegates to ChampionQuizService and ChampionRoleService)ChampionRoleService and ChampionQuizService (both intact, fully usable)_seal.svg.erb and _icon.svg.erb (still used by staff views)champion_signups routes for staff (index, show, update, destroy, duplicates, merge, export_csv)champion_signups Tableid integer PK
first_name string
last_name string
maiden_name string
graduation_year string
email string
phone string
zip_code string
address string (legacy; street/city/state split added later)
interests string (legacy; not used in v1 wizard)
answers jsonb default: {} (quiz answers: {"q0"=>"a","q1"=>"c",...})
result_role string (enum, prefix: result_; set by quiz calculation)
street string
city string
state string
selected_role string (enum, prefix: selected_; explicitly chosen by user)
lifestage_interest string (comma-joined values: "almost-alumni, young-alumni")
belonging_categories jsonb default: [] (array of strings from fixed options)
belonging_note text
vocation string (free text: "what are you up to vocationally?")
vocation_help boolean default: false
buid string (connective tissue to alumni DB / BruinQuest)
deleted_at datetime (soft-delete; active scope: where(deleted_at: nil))
status integer default: 0, not null (enum, see below)
created_at / updated_at
Status enum (current):
| Value | Integer | Meaning |
|---|---|---|
started |
1 | email + graduation_year present |
completed_questions |
2 | result_role calculated |
selected_role |
3 | selected_role set |
interests |
4 | lifestage/belonging/vocation present |
zip_code |
5 | zip_code present — the definition of “completed” |
Authoritative role field: selected_role takes precedence over result_role. The final_role and final_role_key model methods already handle this.
Columns NOT yet in schema (needed for v2.0):
college_code (string) — Belmont college code (FK to colleges.college_code); does NOT existmajor_code (string) — Belmont major code (FK to majors.major_code); does NOT existindustry (string) — current industry; does NOT existjob_title (string) — current role/title (short text); vocation is a different concept (free-text about vocational life) and should be preserved; add job_title as a separate columnaffinity_codes (jsonb, default: []) — array of affinity_code strings from the affinities table; belonging_categories is a SEPARATE concept (felt belonging AT Belmont as a student) and must NOT be repurposedaffinity_other (string) — free-text for affinities not in the listsource (string) — e.g., "champion_signup_v2" to distinguish from v1 recordsNote on result_role vs selected_role: In v1, result_role was set by the quiz calculation and selected_role was set after the user was shown all four role options and could confirm or change. final_role is a model method (not a column) that returns selected_role.presence || result_role. This two-step design is preserved in v2.0: quiz → results shown → user confirms or selects different role → selected_role set. When user skips role entirely, both remain blank and final_role returns nil — the confirmation page handles this gracefully.
app/services/champion_quiz_service.rb)a/b/c/da→connection_advisor, b→digital_ambassador, c→community_builder, d→giving_advocateChampionQuizService.questions, .calculate_primary_role(answers), .calculate_result_roles(answers), .generate_results(answers){"q0"=>"a", "q1"=>"c", ...} (same key format as v1)app/services/champion_role_service.rb)ROLES hash: the single source of truth for all 4 rolesconnection_advisor, digital_ambassador, community_builder, giving_advocateletter, color, emoji, title, seal, description, short_desc, detailed: {tagline, activities[], summary}.all_roles, .role(key), .role_title(key), .role_description(key), .narrative_for(roles)app/helpers/champion_signups_helper.rb)Cp::Champion::INDUSTRIES)healthcare, technology, education, finance, music, film_tv, entertainment,
nonprofit, government, legal, manufacturing, retail, real_estate, hospitality,
consulting, marketing_advertising, other
Affinity.category column)All categories: Athletics, Campus Life, Geographic, Greek Life, Instrumental Ensembles, Post Graduation, Spiritual Life, Vocal Ensembles
For v2.0 affinities step: Exclude Geographic and Post Graduation (matches existing profile wizard behavior). Show: Athletics, Campus Life, Greek Life, Instrumental Ensembles, Spiritual Life, Vocal Ensembles.
The full affinity list is dynamic from the affinities DB table (Affinity.where.not(category: ['Geographic', 'Post Graduation']).order(:category, :name)). Do not hardcode affinity names.
Dynamic from the colleges DB table. Display via college.college_name_short in dropdowns, store college.college_name_short or college_code in champion_signups.college. Confirm with PM before implementation — using college_name_short is simplest and matches how the field will appear in staff view.
v2.0 signup lives inside the existing alumnichampions/belmontalum constraint block in config/routes.rb (already handles alumnichampions.com). Uses the new public/ namespace.
# Inside the existing domain constraint block:
scope module: 'public' do
# v2.0 Public Champion Signup Flow
resource :champion_signup, only: [] do
get 'sign-up', action: :new, as: :new # landing/step=0
post 'sign-up', action: :create # step submission
get 'sign-up/:id', action: :show, as: :show # confirmation page
end
end
Resulting named routes:
new_public_champion_signup_path → GET /sign-up (landing + all steps via ?step=)public_champion_signup_path → POST /sign-uppublic_champion_signup_path(@signup) → GET /sign-up/:id (confirmation)Step navigation pattern (matches v1): All steps are GET /sign-up?step=STEP_NAME. All form submissions are POST /sign-up?step=STEP_NAME. Steps: landing, who_you_are, belmont_experience, where_you_are, question1–question7, quiz_results, role, affinities.
Staff routes (no change): Already defined. resources :champion_signups at Lookup portal level.
/api/majors endpoint (not used): The public and authenticated /api/majors endpoints exist for JS-driven cascading selects. Since v2.0 is server-rendered and in the same codebase, we load major data directly in the controller and render it in the view — no AJAX needed. See the Belmont Experience step section.
File: app/controllers/public/champion_signups_controller.rb
Inherits from: PublicController (uses “public” layout, no auth)
Namespace: Public::ChampionSignupsController
Includes: ChampionSignupsHelper
new@step = params[:step] || 'landing'@signup from session[:champion_signup_id]where_you_are: load @industries = Cp::Champion::INDUSTRIESrole or quiz_results: compute from @signup.answers if presentaffinities: load @all_affinities, @affinity_categories, @selected_affinity_codes (same pattern as profile wizard controller)new (which renders step partial based on @step)createHandle params[:step]:
| Step | Action |
|---|---|
who_you_are |
Create or update signup with personal info params; store ID in session; redirect to ?step=belmont_experience |
belmont_experience |
Update signup with college_code/major_code; redirect to ?step=where_you_are |
where_you_are |
Update signup with industry, job_title; redirect to ?step=role (if pick-a-role) or ?step=question1 (if quiz) or ?step=affinities (if skip) |
question1–question7 |
Same as v1: merge answer into answers jsonb, advance to next question or quiz_results |
quiz_results |
No-op step — just display results; proceed to ?step=role |
role |
Update selected_role (or leave blank if skipped); redirect to ?step=affinities |
affinities |
Update affinity_codes, affinity_other, belonging_note, and any retained vocation fields; set source = "champion_signup_v2"; calculate and save result_role; send emails; clear session; redirect to confirmation |
show@signup = ChampionSignup.find(params[:id])@signup.final_rolePersonal info params: first_name, last_name, maiden_name, graduation_year, email, phone, zip_code
Belmont experience params: college_code, major_code
Where you are params: industry, job_title
Role param: selected_role (string, validated against ChampionRoleService::ROLES.keys)
Affinities params:
params.require(:champion_signup).permit(
{ affinity_codes: [] },
:affinity_other,
:belonging_note,
:belonging_other,
:vocation,
:vocation_help
)
Same as v1: session[:champion_signup_id] stores the signup ID. Cleared after successful submission.
The create action should respond to JSON for the final submission step:
respond_to do |format|
format.html { redirect_to champion_signup_path(@signup) }
format.json { render json: { id: @signup.id, status: @signup.status, confirmation_url: champion_signup_url(@signup) }, status: :created }
end
Error response (JSON): { errors: @signup.errors.full_messages }, status 422.
| Column | Type | Default | Notes |
|---|---|---|---|
college_code |
string | nil | FK to colleges.college_code — use Education::AggregateScope.active_college_codes to filter the dropdown |
major_code |
string | nil | FK to majors.major_code — loaded via cascading major_selector_controller.js + GET /api/majors?college= |
industry |
string | nil | From Cp::Champion::INDUSTRIES list |
job_title |
string | nil | Current professional title; vocation is preserved separately (different question) |
affinity_codes |
jsonb | [] |
Array of affinity_code strings from affinities table |
affinity_other |
string | nil | Free-text for affinities not in the list |
source |
string | nil | Set to "champion_signup_v2" on all v2 submissions |
Confirm before generating migration: Run bin/rails db:schema:dump and diff against spec table above. Do NOT wipe or migrate existing records. All new columns will be null for v1 records — acceptable per spec.
# In ChampionSignup model:
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :industry, inclusion: { in: Cp::Champion::INDUSTRIES }, allow_blank: true
validates :selected_role, inclusion: { in: ChampionRoleService::ROLES.keys + [''] }, allow_blank: true
validates :college_code, inclusion: { in: -> { College.pluck(:college_code) } }, allow_blank: true
validates :major_code, inclusion: { in: -> { Major.pluck(:major_code) } }, allow_blank: true
Note: The college_code and major_code inclusion validations perform DB queries. Wrap in lazy lambdas as shown — and consider whether validating at model level is appropriate for a public signup (validation failure should show a helpful error, not a raw 422). Free-text fallback input for major means major_code may be blank even when the user entered something — handle accordingly.
The current enum treats zip_code (status 5) as “completed.” In v2.0, completion is defined by reaching the affinities submission (final step). The v2 submission does include zip_code (collected in step 1), so existing completed? logic should still work: if zip_code.present? → status 5. No enum changes needed unless we want a v2_submitted status (leave for implementation decision, document deviation if added).
final_role — returns selected_role.presence || result_rolefinal_role_key — returns parameterized key for the final rolecompleted? — returns status == 'zip_code'calculate_status / update_status! — already called via before_saveUses "public" layout (set by PublicController). This layout already exists.
app/views/public/champion_signups/
new.html.erb # Container; renders @step partial
show.html.erb # Confirmation page (personalized by role)
_header.html.erb # Public header with Belmont branding
_progress_bar.html.erb # Step progress indicator
steps/
_landing.html.erb # Step 0: Education/landing page
_who_you_are.html.erb # Step 1: Personal info
_belmont_experience.html.erb # Step 2: College + Major
_where_you_are.html.erb # Step 3: Industry + Title + Role choice
_question.html.erb # Quiz question (expects locals: question, q_index)
_quiz_results.html.erb # Quiz result display; links to role step
_role.html.erb # Role card selection (4 cards with seal SVGs)
_affinities.html.erb # Affinity browse/search (mirror profile wizard)
Note on SVG partials: The _seal.svg.erb and _icon.svg.erb partials are currently at app/views/champion_signups/ (staff view path). The public v2.0 views should reference the same partials via their full path to avoid duplication: render partial: 'champion_signups/seal', formats: [:svg], locals: { ... }. Confirm this path still resolves correctly before implementing — if not, copy SVGs to a shared location (e.g., app/views/shared/champion_signups/).
new.html.erb Pattern (same as v1)<% @banner_size = @step.present? ? :small : :large %>
<%= render "public/champion_signups/header" %>
<div class="max-w-2xl mx-auto px-4 py-8">
<%= render "public/champion_signups/steps/#{@step || 'landing'}" %>
</div>
_landing.html.erbFull-page education section with:
CHRIST_CENTERED__IDENTITY_STATEMENT.md and COMMUNICATIONS__ALUMNI_CHAMPIONS.md)new_champion_signup_path(step: 'who_you_are')Standard form steps following v1 pattern:
form_with url: public_champion_signup_path(step: 'who_you_are'), method: :post, data: { turbo: false }Step 2 — College + Major: direct server-side loading, same pattern as alumni/search.html.erb.
No AJAX, no API calls — load everything in the controller, render with grouped_options_for_select using the existing major-selector Stimulus controller for the cascade behavior:
# In controller, for `belmont_experience` step:
@colleges = College.where(
college_code: Education::AggregateScope.active_college_codes
).order(:college_name)
@majors_by_college = Major.includes(:college)
.where(active: true)
.order("colleges.college_name", :major_desc)
.group_by { |m| m.college.college_name }
.transform_values { |ms| ms.map { |m| [m.major_desc, m.major_code] } }
<%# app/views/public/champion_signups/steps/_belmont_experience.html.erb %>
<div data-controller="major-selector">
<%= f.select :college_code,
options_for_select(@colleges.pluck(:college_name, :college_code), @signup&.college_code),
{ include_blank: "Select your college..." },
data: { major_selector_target: "college", action: "change->major-selector#loadMajors" } %>
<%= select_tag "champion_signup[major_code]",
grouped_options_for_select(@majors_by_college, @signup&.major_code),
include_blank: "Select your major...",
data: { major_selector_target: "major" } %>
</div>
The major-selector Stimulus controller (app/javascript/controllers/major_selector_controller.js) already handles the cascade: selecting a college calls loadMajors() which hits GET /api/majors?college=CODE. Since we’re also pre-loading all majors server-side via @majors_by_college, the initial render shows all majors grouped by college even before JS runs. After college selection, the Stimulus controller repopulates the major select via the API. This is the same behavior as the alumni search.
On Major vs EducationAreaOfStudy: EducationAreaOfStudy is per-person transactional data (one row per person’s enrollment) — not suitable as a dropdown catalog. Major is the canonical reference table and remains so per the Phase 18 backlog decision: “colleges and majors are NOT removable — they remain active reference tables (majors backs the major dropdown, community naming, and banner-import validation).” The alumni search dropdown is correct to use Major. We follow the same pattern here. The separately-raised question of whether the alumni search filter should query through education_areas_of_study.major_code (rather than degrees) is a Phase 18.9 Group C item, already tracked — out of scope for this feature.
_where_you_are.html.erbIndustry select from Cp::Champion::INDUSTRIES:
<%= f.select :industry, options_for_select(
Cp::Champion::INDUSTRIES.map { |i| [i.humanize.titleize, i] }
) %>
Job title — short text input, optional:
<%= f.text_field :job_title, placeholder: 'e.g. Marketing Director, Studio Engineer, 3rd Grade Teacher' %>
Role choice — three equal-weight cards/buttons (not radio buttons; use link buttons):
_role step); submitting this sets selected_role?step=question1 (quiz flow)selected_role blank; proceeds to affinitiesForm submit to ?step=where_you_are; role path choice handled client-side by revealing the appropriate sub-section.
_question.html.erb is identical to v1 — expects locals question and q_index. The controller renders this partial with:
@question = champion_questions[q_index]
@q_index = q_index
_quiz_results.html.erb — same as v1 _results.html.erb but with updated link targets: “Continue with this role →” → champion_signup_path(step: 'affinities') (skips separate role step since quiz result IS the role selection).
_role.html.erb (v2 version)Four role cards using existing seal SVGs and ChampionRoleService::ROLES data. Same visual treatment as v1. Also show a “Skip for now” link below the fieldset.
_affinities.html.erbReuse the profile wizard affinity browse/search interaction pattern, adapted for signup field scope:
cp-affinity-selector controller<input type="hidden" name="champion_signup[affinity_codes][]" value="...">affinity_other text input for groups not in the list — stored in the affinity_other column (new). NOT stored in belonging_note; those are separate concepts.belonging_note as a textarea prompt asking where the alum felt the strongest sense of belonging.Reuse the cp-affinity-selector Stimulus controller — do not create a new one. The controller is at app/javascript/controllers/cp/affinity_selector_controller.js.
Data loading (same as profile wizard):
excluded = ['Geographic', 'Post Graduation']
@all_affinities = Affinity.where.not(category: excluded).order(:category, :name).select(:affinity_code, :name, :category)
@affinity_categories = Affinity.where.not(category: excluded).distinct.pluck(:category).compact.sort
@selected_affinity_codes = [] # empty on first render; repopulated on validation error
Additionally, this step does not capture lifestage_interest or belonging_categories.
show.html.erb — Personalized Confirmation PageIF final_role present:
- Role seal SVG (large, colored)
- "You're a [Role Title]"
- Role description (from ChampionRoleService)
- 2-3 concrete activity examples (from role_data[:detailed][:activities])
- What happens next: "We'll be in touch when we have something that fits you well."
- Optional: newsletter/social CTA
IF no role:
- General Champion description
- "You can always come back to take the quiz or choose a role."
- Link to quiz: new_champion_signup_path(step: 'question1') (only if session[:champion_signup_id] is still valid — otherwise, omit)
- What happens next: same message
Method: POST
Path: /sign-up?step=affinities with Accept: application/json header (or via a dedicated /api/v1/champion_signups endpoint — implementation choice)
Auth: None required
Content-Type: application/json
Request params:
{
"champion_signup": {
"first_name": "Alex",
"last_name": "Smith",
"email": "alex@example.com",
"graduation_year": "2018",
"zip_code": "37201",
"phone": "615-555-1234",
"college": "College of Music",
"major": "Music Business",
"industry": "music",
"job_title": "A&R Manager",
"selected_role": "connection_advisor",
"affinity_codes": ["GKLIFE_SIGMA", "ATHLETICS_SOCCER"],
"belonging_note": "My team was everything.",
"vocation": "Working in artist management",
"vocation_help": true
}
}
Success response (201):
{
"id": 1234,
"status": "zip_code",
"final_role": "Connection Advisor",
"confirmation_url": "https://alumnichampions.com/sign-up/1234"
}
Error response (422):
{
"errors": ["Email can't be blank", "First name can't be blank"]
}
Single-step submission: The API endpoint accepts all fields in one POST (does not require step-by-step). The controller handles this by detecting format.json and processing all params at once via a create_from_api branch or by setting source = "champion_signup_v2_api".
Since v2.0 is integrated into alumni_lookup (same server, same session store), client-side localStorage is not needed. Progress is persisted server-side via the Rails session and the database.
Pattern (same as v1):
session[:champion_signup_id] is written on step 1 creation@signup from ChampionSignup.find_by(id: session[:champion_signup_id])session.delete(:champion_signup_id) clears the session entryQuiz answer shuffle: Shuffle is KEPT in v2.0 (answers randomized per page load via .to_a.shuffle). This is intentional — it prevents positional bias. Since progress is stored server-side, the shuffle doesn’t conflict with saved state (the answer letter a/b/c/d is stored, not the displayed text).
No Stimulus progress controller needed. Remove from the “Files to Create” list.
No change to the existing staff ChampionSignupsController (app/controllers/champion_signups_controller.rb). All v2 signups automatically appear in the existing /champion_signups index because they use the same ChampionSignup model.
The source column (new, string) is set to "champion_signup_v2" on submission. Staff index will show a badge:
params[:source] in filtered_signups method (add to existing filter logic)Add source badge to index table row:
<% if signup.source.present? %>
<span class="badge badge-sm">v2</span>
<% end %>
Add source filter to existing filter dropdown (alongside status/role filters).
Same workflow: staff sees “Not Linked” status, AlumniMatcher suggests potential alumni matches, staff clicks to assign BUID. find_potential_alumni_matches in the staff controller works with v2 records as-is because it uses first_name, last_name, graduation_year, and maiden_name — all collected in step 1.
The existing app/views/champion_signups/show.html.erb will need display sections added for v2 fields: college, major, industry, job_title, affinity_codes (rendered as readable affinity names via Affinity.where(affinity_code: @signup.affinity_codes).pluck(:name)). These sections should be shown with “Not provided” fallback for v1 records where columns are null.
File: app/mailers/champion_signup_mailer.rb (restore this file)
@signup.email"Welcome, #{@signup.first_name} — you're an Alumni Champion!"@signup.completed? is true (zip_code present) after final step submissionChampionRoleService.role(@signup.final_role_key) directly — do NOT duplicate the role data hash inside the mailer as v1 did; delegate to the service)Rails.application.config.champion_signup_admin_emails"New Champion Signup (v2): #{@signup.first_name} #{@signup.last_name}"Restore config/initializers/champion_signup.rb:
Rails.application.config.champion_signup_admin_emails =
ENV.fetch('CHAMPION_SIGNUP_ADMIN_EMAILS', 'alumni@belmont.edu').split(',')
Mailer should use alumnichampions.com host for confirmation links:
def default_url_options
if Rails.env.production?
{ host: ENV.fetch('ALUMNI_CHAMPIONS_HOST', 'alumnichampions.com'), protocol: 'https' }
else
super
end
end
Confirm against db/schema.rb before running. All columns should be additions only — no removals, no type changes.
class AddV2FieldsToChampionSignups < ActiveRecord::Migration[7.1]
def change
add_column :champion_signups, :college_code, :string
add_column :champion_signups, :major_code, :string
add_column :champion_signups, :industry, :string
add_column :champion_signups, :job_title, :string
add_column :champion_signups, :affinity_codes, :jsonb, default: []
add_column :champion_signups, :affinity_other, :string
add_column :champion_signups, :source, :string
add_index :champion_signups, :source
end
end
Pre-migration checklist:
bin/rails db:schema:dump and confirm none of these columns already existbelonging_categories remains untouched (it does; it’s a separate concept)status enum does not need changes (it doesn’t; zip_code = completed still applies)Following the test-as-you-go rules from the codebase:
test/controllers/public/champion_signups_controller_test.rb)new (landing step) renders 200new?step=who_you_are renders 200 without sessioncreate?step=who_you_are with valid params creates record, stores session, redirectscreate?step=who_you_are with invalid params (missing email) re-renders with 422create?step=belmont_experience updates college_code and major_code, redirectscreate?step=question1 through question7 advances quiz state (answers shuffled on render; letter stored correctly)create?step=question1 through question7 advances quiz statecreate?step=role sets selected_rolecreate?step=affinities completes signup, sets source = “champion_signup_v2”, sends emails, redirects to showshow renders confirmation pagecreate?step=affinities with JSON accept header returns JSON 201create?step=who_you_are with JSON accept header and invalid params returns JSON 422create?step=affinities with “skip role” (blank selected_role) completes signup without roletest/models/champion_signup_test.rb)source is stored and readableaffinity_codes defaults to empty arrayfinal_role, completed?, calculate_status still work correctly with v2 recordstest/mailers/champion_signup_mailer_test.rb)welcome_email renders correct role data for a v2 signup with selected_rolewelcome_email renders generic content when no role selectedadmin_notification sends to configured admin emails with v2 subject linetest/controllers/alumni_network/legacy_signup_redirects_test.rb — still valid; /signups/new and /signups/:id still redirect to /alumni_network.
All questions from initial spec review, resolved:
College field storage: Use college_code (FK to colleges.college_code). Load dropdown via College.where(college_code: Education::AggregateScope.active_college_codes).order(:college_name) — this is the established pattern in alumni_controller.rb and statistics_controller.rb.
Major field: Use Major.includes(:college).where(active: true) loaded in the controller, rendered via grouped_options_for_select grouped by college name — exact same pattern as alumni/search.html.erb. The major-selector Stimulus controller handles the cascade on college change (still calls /api/majors?college= for the JS-driven repopulate; the authenticated guard on that endpoint is fine since we pre-load all majors server-side and the JS enhancement is progressive). Store major_code. EducationAreaOfStudy is per-person enrollment data and is not a dropdown catalog — Major is the right source.
Role flow / skip handling: result_role = quiz calculation output; selected_role = user’s final confirmed choice (may differ from quiz result). Skip role = both blank, final_role returns nil. This is acceptable and handled by the confirmation page’s “no role” branch.
Affinity “Other” field: Add a new affinity_other (string) column. Keep belonging_note as-is for the separate “tell us more about your belonging” text area. These are distinct questions and should not share a column.
4a. No lifestage/belonging-categories capture in v2 flow: The new flow should not collect lifestage_interest or belonging_categories; affinity selection plus belonging_note is the intended scope.
Status enum for v2: No changes needed. source = "champion_signup_v2" is sufficient to distinguish v2 records. Existing zip_code status = completed still applies.
Activity event tracking: Skip. This signup is unauthenticated — no Cp::Champion exists at submission time. Activity recording can be triggered later via the BUID matching / verification pipeline.
Session-based progress: No localStorage needed. Progress is persisted via Rails session + DB (same pattern as v1). See Session-Based Progress Persistence section above.
Quiz answer shuffle: KEEP the shuffle (question[:options].to_a.shuffle). Since progress is server-side (answer letters stored, not displayed text), shuffle doesn’t conflict with saved state. Shuffle intentionally prevents positional bias.
db/migrate/TIMESTAMP_add_v2_fields_to_champion_signups.rbapp/mailers/champion_signup_mailer.rbapp/mailers/champion_signup_mailer/welcome_email.html.erbapp/mailers/champion_signup_mailer/welcome_email.text.erbapp/mailers/champion_signup_mailer/admin_notification.html.erbapp/mailers/champion_signup_mailer/admin_notification.text.erbconfig/initializers/champion_signup.rbapp/controllers/public/champion_signups_controller.rbapp/views/public/champion_signups/new.html.erbapp/views/public/champion_signups/show.html.erbapp/views/public/champion_signups/_header.html.erbapp/views/public/champion_signups/_progress_bar.html.erbapp/views/public/champion_signups/steps/_landing.html.erbapp/views/public/champion_signups/steps/_who_you_are.html.erbapp/views/public/champion_signups/steps/_belmont_experience.html.erbapp/views/public/champion_signups/steps/_where_you_are.html.erbapp/views/public/champion_signups/steps/_question.html.erb (restore from v1, update route helpers)app/views/public/champion_signups/steps/_quiz_results.html.erbapp/views/public/champion_signups/steps/_role.html.erb (restore + update links)app/views/public/champion_signups/steps/_affinities.html.erbresource :champion_signup block (module: ‘public’) inside constraintRemoved from list (vs. initial draft): champion_signup_progress_controller.js (localStorage not needed; server-side session used instead)
config/routes.rb — add resource :champion_signup (module: ‘public’) inside the domain constraint block; leave /signups/new and /signups/:id redirects as-isapp/controllers/champion_signups_controller.rb — add source filter in filtered_signupsapp/views/champion_signups/show.html.erb — add v2 field display for college_code, major_code, industry, job_title, affinity_codes, affinity_otherapp/views/champion_signups/index.html.erb — add source badge columnapp/models/champion_signup.rb — add validations for new fields; no structural changesRemoved from list (vs. initial draft): config/importmap.rb (no new Stimulus controller needed)