alumni_lookup

Alumni Champions Signup v2.0 — Feature Spec

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)


Phase Plan

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

Phase 19.1 — Completion Summary

Shipped: 2026-06-08

What Was Implemented

Test Results

Files Created

Files Modified

Notes / Deviations from Original Spec

Phase 19.1 — Foundation: Schema, Routes, Controller Shell

Goal: Scaffold is live. alumnichampions.com/sign-up renders the landing page (read-only, no submission yet). All other steps redirect to landing.

Phase 19.2 — Personal Info Flow (Steps 1–3) ✅ Complete

Goal: Working signup through the first three data steps with a clean, low-friction data-capture UX.

What was implemented:

Spec deviations (approved):

  1. Graduation year is free text (graduation_year) to support alumni who want to provide multiple years.
  2. Belmont experience is a single free-text field (belmont_background) instead of normalized college/major selectors.
  3. Role selection moved out of Step 3 so profession capture is not conflated with role education.
  4. Temporary 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.

Phase 19.3 — Quiz Flow + Role Selection + Light Interests ✅ Complete

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:

Spec deviations (approved during planning):

  1. Role hub is the Role-stage entry point (“Start at Role”). The quiz is an optional path reached via a CTA, rather than the quiz being forced first.
  2. Quiz results route back to the role hub to confirm (“show results, then role”) instead of finalizing directly on the results page — this preserves the direct-pick + quiz-recommendation duality on one surface.
  3. Light Interests implemented in 19.3 (category checkboxes + note) instead of deferring all affinities to 19.4. In 19.4 this is replaced by affinity browse/search + belonging_note (no lifestage or belonging-category capture).
  4. Skip-for-now path restored after UX review: role remains optional at signup completion (aligns with Champion posture docs).

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.

Phase 19.4 — Completion Summary

Shipped: 2026-06-09

What Was Implemented

Test Results

Spec Deviations (Approved)

Deferred

Phase 19.4 — Full Affinities, Submission + Mailer

Goal: Form completable end-to-end. Submissions appear in staff queue. Emails send.

Phase 19.5 — Confirmation Page + Staff Queue Integration

Goal: Experience complete for user and staff.

Phase 19.5 — Planning Checkpoint (2026-06-09)

Confirmed Scope

Additional Staff Queue Filtering (approved)

Confirmation UX Decisions

  1. No-role branch may show a role follow-up CTA only when a valid signed signup token is present.
  2. Role branch should render the full role activity list.

Data Rendering Decision

Explicitly Deferred / Removed

Phase 19.5 — Completion Summary

Shipped: 2026-06-09

What Was Implemented

Test Results

Phase 19.6 — Completion Summary

Shipped: 2026-06-09

What Was Implemented

Test Results

(See docs/planning/phases/phase-19/README.md for full 19.6 detail.)


Phase 19.7 — Planning Checkpoint (2026-06-24)

Why

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.

Confirmed Scope

Explicitly Deferred

Migration

None anticipated.

Completion Summary (Shipped 2026-06-25)

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; roleinterests rename deferred to 19.8. Full detail in docs/planning/phases/phase-19/README.md.


Phase 19.8 — Planning Checkpoint (2026-06-24)

Why

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.

Confirmed Scope

Explicitly Deferred

Migration


Architecture Decision: Stay in alumni_lookup

The Question

With 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?

Decision: Integrate into alumni_lookup

Rationale: A standalone repo for Champion Signup would immediately need to duplicate or re-fetch:

Domain 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.

Monolithic vs. Multiple Repos — Longer-Term View

This is worth a deliberate conversation before Phase 18 wraps. The current trajectory:

Recommendation 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.


Namespace Clarity

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:

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.


Background: What Was Deleted in Commit 3120a37

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:


Existing Schema Inventory: champion_signups Table

id                  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):

Note 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.


Reusable Services and Helpers

ChampionQuizService (app/services/champion_quiz_service.rb)

ChampionRoleService (app/services/champion_role_service.rb)

ChampionSignupsHelper (app/helpers/champion_signups_helper.rb)


Industry & Affinity Reference Data

Industries (from Cp::Champion::INDUSTRIES)

healthcare, technology, education, finance, music, film_tv, entertainment,
nonprofit, government, legal, manufacturing, retail, real_estate, hospitality,
consulting, marketing_advertising, other

Affinity Categories (from 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.

Colleges

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.


Route Structure

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:

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, question1question7, 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.


Controller Structure

File: app/controllers/public/champion_signups_controller.rb Inherits from: PublicController (uses “public” layout, no auth) Namespace: Public::ChampionSignupsController Includes: ChampionSignupsHelper

Actions

new

create

Handle 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)
question1question7 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

Parameter Whitelist

Personal 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
)

Session Pattern

Same as v1: session[:champion_signup_id] stores the signup ID. Cleared after successful submission.

API Endpoint (JSON)

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.


Model Changes

New Columns (migration needed)

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.

New Validations

# 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.

Status Enum — v2 Considerations

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).

Existing Model Methods (usable as-is)


View Structure

Layout

Uses "public" layout (set by PublicController). This layout already exists.

File Structure

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>

Step 0: _landing.html.erb

Full-page education section with:

Steps 1–2: Personal Info and Belmont Experience

Standard form steps following v1 pattern:

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.

Step 3: _where_you_are.html.erb

Industry 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):

  1. “I know my role” → shows all 4 role cards inline (same as _role step); submitting this sets selected_role
  2. “Help me decide” → redirects to ?step=question1 (quiz flow)
  3. “I’ll decide later” → submits step with selected_role blank; proceeds to affinities

Form submit to ?step=where_you_are; role path choice handled client-side by revealing the appropriate sub-section.

Quiz Steps (reuse v1 partials)

_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.erb

Reuse the profile wizard affinity browse/search interaction pattern, adapted for signup field scope:

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 Page

IF 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

API Endpoint Spec

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".


Session-Based Progress Persistence

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):

Quiz 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.


Verification Queue Integration

How v2 Signups Surface in the Lookup Portal

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.

Source Tag

The source column (new, string) is set to "champion_signup_v2" on submission. Staff index will show a badge:

Staff Index Changes (minimal)

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).

BUID Linking (no change)

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.

Staff Show Page (new columns)

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.


Mailer Spec

File: app/mailers/champion_signup_mailer.rb (restore this file)

welcome_email

admin_notification

Initializer

Restore config/initializers/champion_signup.rb:

Rails.application.config.champion_signup_admin_emails =
  ENV.fetch('CHAMPION_SIGNUP_ADMIN_EMAILS', 'alumni@belmont.edu').split(',')

URL Options

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

Migration Spec

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:


Test Coverage Requirements

Following the test-as-you-go rules from the codebase:

Controller Tests (test/controllers/public/champion_signups_controller_test.rb)

Model Tests (test/models/champion_signup_test.rb)

Mailer Tests (test/mailers/champion_signup_mailer_test.rb)

Redirect Tests (existing file, no changes needed)

test/controllers/alumni_network/legacy_signup_redirects_test.rb — still valid; /signups/new and /signups/:id still redirect to /alumni_network.


Resolved Design Decisions

All questions from initial spec review, resolved:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  1. Status enum for v2: No changes needed. source = "champion_signup_v2" is sufficient to distinguish v2 records. Existing zip_code status = completed still applies.

  2. 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.

  3. Session-based progress: No localStorage needed. Progress is persisted via Rails session + DB (same pattern as v1). See Session-Based Progress Persistence section above.

  4. 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.


Files To Create (Ordered by Dependency)

  1. Migration: db/migrate/TIMESTAMP_add_v2_fields_to_champion_signups.rb
  2. app/mailers/champion_signup_mailer.rb
  3. app/mailers/champion_signup_mailer/welcome_email.html.erb
  4. app/mailers/champion_signup_mailer/welcome_email.text.erb
  5. app/mailers/champion_signup_mailer/admin_notification.html.erb
  6. app/mailers/champion_signup_mailer/admin_notification.text.erb
  7. config/initializers/champion_signup.rb
  8. app/controllers/public/champion_signups_controller.rb
  9. app/views/public/champion_signups/new.html.erb
  10. app/views/public/champion_signups/show.html.erb
  11. app/views/public/champion_signups/_header.html.erb
  12. app/views/public/champion_signups/_progress_bar.html.erb
  13. app/views/public/champion_signups/steps/_landing.html.erb
  14. app/views/public/champion_signups/steps/_who_you_are.html.erb
  15. app/views/public/champion_signups/steps/_belmont_experience.html.erb
  16. app/views/public/champion_signups/steps/_where_you_are.html.erb
  17. app/views/public/champion_signups/steps/_question.html.erb (restore from v1, update route helpers)
  18. app/views/public/champion_signups/steps/_quiz_results.html.erb
  19. app/views/public/champion_signups/steps/_role.html.erb (restore + update links)
  20. app/views/public/champion_signups/steps/_affinities.html.erb
  21. Routes update: add resource :champion_signup block (module: ‘public’) inside constraint
  22. Staff show view update: add v2 field display sections
  23. Staff index update: add source badge and filter
  24. Test files: controller, model (additions), mailer

Removed from list (vs. initial draft): champion_signup_progress_controller.js (localStorage not needed; server-side session used instead)

Files To Update (Existing)

Removed from list (vs. initial draft): config/importmap.rb (no new Stimulus controller needed)