alumni_lookup

Champion Signup System (Legacy)

Canonical sources: Portal philosophy, posture, and language live in /docs/planning/alumni-network/source/README.md.
Use these sources (including CHRIST_CENTERED__IDENTITY_STATEMENT.md) when writing specs or user-facing copy. Prefer quoting/paraphrasing over inventing new language.

Version: 2.0
Last Updated: June 2026 (Phase 19.8 complete — phase closed)
Status: ✅ v2.0 Public Flow Complete (19.1–19.8) — Staff Management & Data Pipeline Active


v2.0 Relaunch (Phase 19 — In Progress)

The alum-facing signup flow is Signup::SignupsController at alumnichampions.com/sign-up. This was a clean rebuild — not a restoration of the deleted Champions::ChampionSignupsController.

Renamed in Phase 23.2 (July 2026). Originally built as Public::ChampionSignupsController in a public/ namespace. That name described auth state, which was the wrong axis — the session-gated returning-visitor hub lives here too. The namespace now names its audience: Signup:: for alumni, SignupAdmin:: for staff. Alum-facing URLs did not change; only module and route-helper names did.

Phase 19 Status

Sub-phase Deliverable Status
19.1 Schema (7 new columns), domain-isolated routes, controller shell, landing page ✅ Complete
19.2 Steps 1–3 shipped with simplified UX + ordered flow scaffolding ✅ Complete
19.3 Role hub (pick or quiz), question1–7, quiz_results, light interests, role-celebrating confirmation ✅ Complete
19.4 Full affinities (browse/search), submission, mailer ✅ Complete
19.5 Confirmation page, staff queue integration ✅ Complete
19.6 Post-feedback UX revision pass ✅ Complete
19.7 Congrats/Profile hub + flow reorder (completion at step 1) ✅ Complete
19.8 Interests redesign — interest spectrum replaces single-winner role quiz ✅ Complete

Current Public Flow (as shipped — updated in 19.7)

As of Phase 19.7 the flow is hub-and-spoke, not linear. Completion happens at step 1; everything after is optional and returns to the profile hub.

  1. who_you_are — creates ChampionSignup, sets session, and this is the completion point: welcome + admin emails fire here, then the user lands on the profile hub. As of 28.3 it collects identity only: email, first name, last name, graduation year (plus “Continue with Google”, which fills the first three). ZIP, phone, SMS consent and maiden name moved to where_you_are — see “Step One Is Identity-Only” below.
  2. Congrats/Profile hub (GET /sign-up/:id) — “You’re in!” celebration + returnable “update your info” page. Shows optional-section progress, three section cards, a contact-info edit link, and (for BUID-linked records) an education-record confidence note. Access-gated: requires the signup session or a valid :profile_access token; otherwise redirects to /sign-in (Phase 27.2; it redirected to the landing page’s prompt before that form moved).
  3. (optional) affinities — full affinity browse/search (affinity_codes + affinity_other + belonging_note); returns to the hub.
  4. (optional) where_you_areZIP, phone, SMS consent and maiden name (28.3), then employment status + company/job/industry; returns to the hub. The contact block renders first, because the hub’s missing-ZIP banner now lands people here.
  5. (optional) role (internal step key; labeled “Interests” on the hub) — quiz-only as of 19.8: if already answered, shows the interest spectrum with a “Retake the quiz” link; otherwise prompts to take the quiz or skip. question1question7quiz_results (spectrum reveal) → back to the hub.

Dropped in 19.7: the belmont_experience step (we already have education from the alumni record; surfaced as a confidence note instead). The belmont_background column is retained for legacy records and staff export/email.

Return access: the welcome email and the /sign-in page’s “email me a link” form (POST /profile-link) send a short-lived (72h) :profile_access token link to the hub, plus a 15-minute six-digit code (27.7). The request form always responds generically to prevent email enumeration. The form lives on /sign-in and nowhere else as of 27.8 — the landing and thank-you copies are links to it.

Source tagging and submission mailers are live (source = "champion_signup_v2", welcome + admin notifications via deliver_later at step 1).

Per approved scope, this flow does not capture lifestage_interest or belonging_categories.

Phase 19.3 Follow-up UX/Robustness Notes

Phase 19.5 Completion Notes

Phase 19.8 Completion Notes (Interests Redesign)

Phase 19.10 Completion Notes (Full Education Note + Return Tracking)

Profile hub shows every degree. The hub’s “we have you down as…” note used to name a single college taken from the first education row. A double-degree alum saw only one of them and reported it as us not knowing them (“I graduated with 2 degrees and would associate with College of Business (BBA) more than my education degree”). ChampionEducationService.for_signup now returns every awarded degree via Alumni::EducationProfile#all_entries (Education-first with legacy Degree fallback), oldest first, deduped on degree/program/college.

Return activity — did re-outreach work? Two denormalized columns on champion_signups, stamped by ChampionSignupEvent’s after_create callback and backfilled from existing events in the migration:

Scopes: returned, enriched, returned_or_enriched, never_returned. Predicates: returned?, enriched?.

Surfaced in three places:

Where What
/champion_signups/all “Came back” / “Added info” chips per row + a Return Activity filter whose counts follow the v1/v2 tab
/champion_signups/:id Header summary (“Last added information 2 days ago”) + an Activity timeline of ChampionSignupEvent rows in plain language
/champion_signups/insights v1 came-back vs added-info split, which sections returners filled in, and a v2 comparison line (rates-only as of Phase 19.12 — individual returners moved to the Dashboard activity feed)
/champion_signups (Dashboard) Activity feed naming who signed up / re-engaged and what they touched (Phase 19.12)

Notification. NotifyChampionSignupReturnJob fires on the first enrichment per signup — once per person, not once per section — creating a StaffNotification (champion_signup_return) per admin/portal_admin, which fans out to web push via the model’s after_create_commit. Deliberately no email: re-outreach campaigns would flood the admin inbox. Brand-new signups still go through NotifyChampionSignupAdminsJob (email + push).

Why columns rather than an events subquery: the list view badges and filters every row, and champion_signup_events grows per interaction. The stamps keep the queue a single indexed scan.

Phase 19.10 Bugfixes (Return-Visit Access)

Three defects found while testing the v1 re-outreach links, all on the public flow:

  1. Stale session swallowed the profile token. load_signup was if session… elsif token…, so a session[:champion_signup_id] pointing at a soft-deleted/vanished record blocked the token branch and left @signup nil. Now the explicit token resolves first and the session is a fallback; a session id that resolves to nothing is deleted. Token-first also fixes a data-integrity hazard: with session-first, opening someone else’s emailed link while holding your own session rendered their hub but loaded your record into the edit form.
  2. editing_who_you_are? did its own token lookup and so could disagree with load_signup — returning true while @signup was nil, which skipped the ChampionSignup.new fallback and 500’d the view (undefined method 'maiden_name' for nil). It now reads the single resolution (@token_signup). The STEPS_REQUIRING_SIGNUP guard also dropped the token on redirect, landing emailed-link users on a blank step 1; an unresolvable token now goes to the expired-link prompt instead.
  3. The spam guard’s 3-second minimum rejected humans on the one-field “email me a link” form. SignupHoneypot.spam? takes min_seconds: now; request_link passes SHORT_FORM_MIN_SECONDS (1s). The 6-field signup form keeps 3s. Because the guard responds with a silent fake success, the failure was invisible to the user — the [honeypot] blocked log line was the only evidence.

Net effect for a returning v1 signup: the hub’s section links and “Edit” now land on the real step, pre-filled with everything already on file. No separate “returning user” page is needed — the optional sections already are that page. The contact-info form shows the Privacy Policy as a plain disclosure, not a checkbox (see Phase 19.11).

The public signup once carried three consent controls (Privacy Policy checkbox, newsletter opt-in, SMS opt-in), and briefly a dedicated reach_you consent step with a required: true gate. That gate made acceptance the price of an unrelated edit — a pre-policy alum fixing a typo in their ZIP had to accept the policy to save — which isn’t freely given consent. Rather than relocate the coercion, 19.11 reduced each control to what the law actually requires:

  1. Privacy Policy → disclosure, no checkbox. A privacy policy is a disclosure, not a contract; no law requires collecting affirmative assent to it. The who_you_are form shows a plain summary + link, and submitting is the acknowledgment. finalize_communication_consent(creating:) stamps privacy_policy_accepted_at/_version on a new signup (recording which version was presented), and never on an edit — so v1 legacy rows that predate the policy are never given a consent they never saw.
  2. Newsletter opt-in → dropped entirely. General alumni email is CAN-SPAM opt-out, there was no integration syncing the box to the newsletter tool, and Belmont runs its own public signup page. The marketing_opt_in / marketing_opt_in_at columns were removed; the newsletter is promoted through other channels rather than cluttering the signup hub with a checkbox (or link) that pointed at something we don’t sync.
  3. SMS opt-in → the one real gate, made contextual. TCPA genuinely requires prior express consent, so this stays. It rides on the who_you_are phone field: the SMS checkbox reveals (via the sms-consent Stimulus controller) only once a number is entered, and finalize_communication_consent clears it whenever the phone is blank. Because the ask now lives with the phone, the standalone reach_you step was removed (route, handler, params, partial).

Export carries consent. Csv::ChampionSignupExporter::HEADER includes privacy_policy_accepted_at, privacy_policy_version, sms_opt_in(_at). A list handed to whoever sends a text carried no record of who agreed to what — the export was the actual leak. Blank/No means no recorded consent, which for SMS means do not text.

SMS consent doesn’t sync anywhere. There’s no integration with any texting tool. A ChampionSignup after_commit records a consent_changed event and fires NotifyChampionSignupConsentChangeJob so each change becomes an explicit staff task (“Add to / REMOVE from the text-message list: Name (phone)”). Every change fires — each is a separate list edit — and the event row is the only permanent record of an opt-out, since sms_opt_in_at is NULLed on withdrawal. Reconcile from the export if the alerts are ever missed.

Consent tiering that drove this (confirm with compliance, not legal advice): SMS genuinely gates behavior (TCPA wants prior express written consent); general alumni email is opt-out under CAN-SPAM, so it needs no checkbox; the policy acknowledgment is evidentiary hygiene, not a precondition for holding data volunteered on a form.

Phase 19 Closeout Status

Phase 19 is complete (19.1–19.8 shipped); 19.10 and 19.11 shipped as follow-ups.

Additional Columns Added After 19.1

New Schema Columns (Phase 19.1)

Added to champion_signups:

Routes (Phase 19.1)

See full spec: docs/planning/champion-flow/SPEC.md

Sign-In (Phase 27.2)

Spec: docs/planning/phases/phase-27/27.2-signin-front-door.md

Passwordless, permanently (Phase 27 Decision A). No password field on this surface now or later — the data is name, grad year, contact info and interests, not financial or health. “Signing in” means clicking a link we email you.

GET /sign-in is a dedicated page wrapping the “email me a link” form, which until 27.2 was reachable only by scrolling the landing page.

27.8 made it the only one. The landing page and the opportunity thank-you page each carried a copy; both are now links to /sign-in. The copies had drifted within one sub-phase — 27.7 added the code box and the consent checkbox to /sign-in alone, so submitting either copy produced an email whose lead instruction (“enter this code on the page you were on”) pointed at a page with nowhere to enter it. Gone with them: the entry_point param and hidden field, signin_entry_point?, and the two-branch link_sent_path.

The confirmation redirect carries the submitted address back so the code box arrives prefilled. It is echoed from the visitor’s own input, and it rides the honeypot branch too — a redirect that differs between a trapped and a real submission is the tell that the trap exists.

ChampionSignupEvent’s link_requested type has no writer left (every send now begins at /sign-in and records signin_link_sent). It stays valid so historical rows keep their meaning, and a test asserts nothing creates a new one.

Two email shapes, one endpoint (mode)

POST /profile-link sends one of two emails, chosen by a mode param that answers a single question: is there a code box in front of this person right now?

mode Sent from Email
code (default) /sign-in — its code box appears the moment the send completes Leads with the six digits; profile link demoted to a small text line
link step 1’s duplicate-check notice, which keeps them on a half-filled form Prominent profile button, no code issued at all

Three things that are easy to get wrong here:

Enumeration-neutral by contract. Known and unknown addresses get an identical response. Do not add a “we found you” branch to the form or to #request_link — a conditional message is a yes/no oracle for “is this person in your database.”

Session adoption. Three entry points establish session[:champion_signup_id] (2 weeks, secure in production):

Entry point Proof
#handle_who_you_are Just signed up
#access (/profile/:code) Never-expiring access code
#show (/sign-up/:id?signup_token=) 72-hour profile token — added in 27.2

Before 27.2 the token path was the only one that did not adopt, so a magic-link visitor was carrying a credential in the URL rather than being signed in: the hub worked (it threads its own token) and every other page treated them as anonymous. Token still outranks session, so opening someone else’s valid link switches you to their record — matching #load_signup’s resolution order.

Header state (signup/signups/_header): first name plus a “Sign out” button_to when session_signup is present, otherwise a “Sign in” link. First name only — Phase 27.3’s recognized tier shares this partial and must never render PII beyond that. session_signup is a helper_method on Signup::BaseController because the header renders on every page of the surface.

Sign out reuses DELETE /sign-up/session with intent=sign_out, landing on the landing page with a confirmation. Without the param the action keeps its original “Start a new signup” behavior (blank form) for shared devices.

Step One Is Identity-Only, and “Completed” Means Something (Phase 28.3)

Step 1 collects email, first name, last name and graduation year. For someone arriving through Google, graduation year is the only field they type. ZIP, phone, SMS consent and “last name while at Belmont” moved to the top of where_you_are.

The fields are removed from the permit list, not just the form. Leaving zip_code permitted would let a crafted POST keep setting it at step 1, which is precisely what caused the defect below — the form change alone would have left it reachable.

Why this was also a bug fix. calculate_status derives status from which fields are filled, and its first branch was zip_code.present? → 5, which status_label prints as “Completed”. ZIP became a step-1 field around November 2025, so from then on every signup was Completed the moment it was created — five active rows reported Completed having done nothing else. It was not cosmetic: Phase 25.5 collapsed the Alumni Champion Signup filter from six options to two because of it (alumni.rb:362).

The top rung now requires a ZIP and evidence of progress past step 1 — started_the_funnel? (a quiz result, a chosen role, or an interests answer):

if zip_code.present? && started_the_funnel?
  5 # completed

The breadth of that evidence is deliberate. v1’s five-step wizard ended at ZIP and never collected employment data, so requiring the v2 “Where you are now” fields would demote 131 genuine v1 completions; requiring the rung immediately below still demotes 12 v1 rows that skipped the interests step. Any-progress changes exactly the 5 defective rows and nothing else. The enum, scope :completed, status_label, Alumni prospect logic and Signup Admin filters are all untouched — only which rows land where.

A backfill could not have fixed this, and the attempt is instructive: before_save :ensure_status_updated re-derives status on every write, and moving a field in a form never removes values already stored. bin/rails champion_signups:recalculate_status (dry-run; CONFIRM=1 applies) exists to make stored values agree with the corrected rule — run it after deploying, expect 5 rows, all zip_code -> started.

Two consequences worth knowing before someone reports them as regressions:

TCPA consent-clearing moved to the model. “Clear sms_opt_in when the phone is blank” lived in Signup::SignupsController#finalize_communication_consent, which only the step-1 handler calls — so moving the phone field would have left the rule with the one step that can no longer set it. It is now #stamp_opt_in_timestamps, an invariant of the record holding for every writer.

Google Sign-In (Phase 28.2)

POST /auth/signup_google → Google → GET /auth/signup_google/callbackSignup::OmniauthCallbacksController#googleSignup::GoogleIdentity.resolve.

The provider is registered twice, under two names. /auth/google_oauth2/callback on alumnichampions.com already resolves to Cp::OmniauthCallbacksController — the portal’s host constraint at the top of routes.rb matches the signup domain as well as its own. Narrowing that constraint is a held-surface write; declaring a route above it works until someone reorders the file and fails silently when they do. A second provider name (strategy_class: OmniAuth::Strategies::GoogleOauth2, same credentials, same Google project) collides with nothing. The redirect URI registered in Google Cloud Console must be the signup_google path; the wrong one produces a redirect_uri_mismatch that reads as a credentials problem and isn’t.

One button, two pages, one resolver. “Continue with Google” renders on /sign-up and /sign-in and both post to the same endpoint. The destination follows the match, never the origin — on /sign-up it signs people in about two-thirds of the time, which is also why the label is not “Sign up with Google”.

# Match on Result Events
1 google_uid Sign in signin_completed (method: "google")
2 Verified email → find_active_by_email Sign in, store google_uid + google_linked (auto: true, matched_by: "email")
3 Verified email → AlumniLookupService → BUID → active signup Sign in, store google_uid, email unchanged + google_linked (matched_by: "buid")
4 Nothing No record created. Prefilled signup form on completion: google_linked (auto: false)

Four things about this that are easy to get wrong:

Outcome 4’s identity rides in the session, not the URL and not a hidden field, and the server overwrites the submitted address with the verified one on create. A readonly attribute is trivially bypassed by a crafted POST; without the server half, “prefilled from Google” would be a suggestion. DELETE /sign-up/session clears it, so a shared device cannot attach one visitor’s Google account to the next visitor’s signup.

Because it rides in the session, backing out needs its own affordance. The email is locked and the landing page’s “Get Started” returns to the same prefilled form, so a visitor who picked the wrong Google account was previously stuck. Two exits, both calling the same reset action: a “Not you@example.com? Start over” link inside the form’s hand-off notice, and a landing banner naming the pending account with Continue and Start over. Making “Get Started” reset was considered and rejected — it silently drops the Google link for someone who returned to the landing to read about the program and then carried on, which is the commoner direction.

Connected accounts on the profile hub shows the connection date and a Disconnect action (google_unlinked). Unlinking needs none of the portal’s “last credential” logic because passwordless is permanent — the link and the code are always a floor. The card renders only when something is linked; connecting happens by signing in with Google, not from the card.

The button hides itself when GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are absent, via Signup::BaseController#signup_google_available?, which reads Devise’s registry rather than the env vars. Without the provider there is no middleware at /auth/signup_google.

/auth/failure is shared by both providers and belonged to the portal, so a signup visitor declining consent landed on the Alumni Network login page. It is captured for signup_google only, constrained on the strategy param OmniAuth appends; google_oauth2 falls through unchanged, and a test asserts that negative.

“Home” Moves When You Sign In (Phase 27.8)

Signup::BaseController#signup_home_path is the single definition of where home is on this surface: the profile hub when signed in (token attached, so it survives a missing session), the landing page otherwise. The header logo uses it, and Signup::SessionsController was collapsed onto it — it had grown a private profile_path_for doing the signed-in half.

steps/_landing has two modes, selected by one education_mode flag read per section:

  anonymous signed in
Hero Stay connected. Make an impact. → Get Started You’re an Alumni Champion, Name. → Go to my profile
Interest areas “you’ll tell us where you lean after you sign up” “you can change where you lean anytime from your profile”
Bottom Ready to jump in? → Start Signing Up Want to add more? → Open my profile
Returning section Already signed up? → Sign in hidden

The middle of the page — what an Alumni Champion is, the four interest areas — is identical in both, which is why it is one template with a flag rather than two templates.

This was a real defect, not a polish item. “Get Started” links to new_signup_path(step: "who_you_are") with no signup_token, and handle_who_you_are reads a missing token as creating — so a signed-in alum following it opened a second signup under their own address, directly beneath a banner greeting them by name. The kiosk path that legitimately wants a fresh record (“Not you? Start a new signup” on the resume banner) clears the session first and is unchanged.

When adding a call to action to this page, branch it. The tests assert absences (assert_select ... false on the create form’s href) precisely because a returning CTA is the failure mode and a presence-only assertion cannot see one.

Remembered Devices (Phase 27.3)

Spec: docs/planning/phases/phase-27/27.3-remembered-devices.md

Three tiers, resolved once per request by Signup::Identity (app/services/signup/identity.rb), exposed as the signup_identity helper on Signup::BaseController:

Tier Proof Grants
anonymous nothing nothing
recognized a valid champion_device cookie, and nothing stronger First name and a masked email only. No hub, no edits, no unmasked PII. Always beside a “Not you?”
verified active signup session, valid profile token, or access code The full hub and every edit

Resolution order is explicit credential beats ambient one: profile token → session → device cookie, matching #load_signup.

The recognized restriction is the safety property, not a UI preference. It is the only reason a 6-month cookie is defensible on a shared or kiosk computer, so the tests that matter are the negative ones — cannot open the hub, cannot save an edit, never sets session[:champion_signup_id]. If you are adding anything to the header or the recognized banner, that is the constraint to check first.

Signup::Identity does not resolve the session itself — it receives an already-resolved session_signup, because Signup::BaseController#session_signup is the single place a session id becomes a record (27.1 §5.1) and repairs the session as a side effect.

ChampionSignupSession stores a SHA-256 digest, never the raw token; the raw value exists only in the cookie. Issued by Signup::BaseController#remember_device!, rotated on each verified sign-in, destroyed by #forget_device!.

Consent (Decision E) is an unticked checkbox on /sign-in only. It travels to the device in the emailed link (rd=1), not in the requesting browser’s session, because people tick the box on a laptop and open the email on a phone — the device that opens the link is the one remembered. si=1 marks a link that began at /sign-in so signin_completed pairs with signin_link_sent.

Merge and deletion pull opposite ways, and both are correct:

Retention: bin/rails champion_signup_sessions:prune (daily; DRY_RUN=1 supported) plus :stats. Expired rows are already inert before the prune runs, since find_by_token scopes to active.

Typography and Third-Party Origins (Phase 27.6)

Montserrat is self-hostedapp/assets/fonts/ (10 woff2 subsets + OFL.txt), app/assets/stylesheets/fonts.css.erb, rendered via shared/_fonts. Consumers: the signup, signup_admin, application, tools and settings layouts. app/assets/fonts is added to the Sprockets load path in config/initializers/assets.rb and linked in manifest.js; without both, font_path raises at compile time.

The alum-facing signup layout makes no requests to Google. Before 27.6 every page load disclosed the visitor’s IP to fonts.googleapis.com and fonts.gstatic.com — no cookie, which is why the cookie policy’s “third-party cookies: no” was literally true while doing work it could not do.

One third-party origin remains and is disclosed rather than hidden: cdn.jsdelivr.net serves five JS modules (flowbite, trix, chart.js, chart.js/auto, @kurkle/color) pinned in config/importmap.rb and eagerly fetched via modulepreload. Both the privacy and cookie policies name it. The baseline is a ratchetALLOWED_EXTERNAL_ORIGINS in test/integration/signup_compliance_test.rb may shrink, never grow. Do not add an origin to make that test pass; the alum-facing surface is where the no-tracking claims live. Vendoring is backlogged with CSP enforcement.

PRIVACY_POLICY_VERSION is 2026-08-08. Bump it for any material policy change, or acceptance records point at text the person never saw.

Rate Limiting (Phase 27.2)

config/initializers/rack_attack.rb. Before 27.2 the live endpoints had nothing but the global req/ip 300/5min and SignupHoneypot; the three champion-* throttles that looked like they covered this surface actually guarded the frozen portal’s Devise routes and are now named portal-*.

Throttle Limit Key
signup-creates/ip 5 / 30 min IP
signup-creates/email 3 / 15 min champion_signup[email]
profile-link/ip 5 / 15 min IP
profile-link/email 3 / 15 min email — closes the email-bomb vector
signup-email-check/ip 10 / hour IP — live as of 27.1 (POST /sign-up/email-check)

Both signup-create throttles are scoped to the tokenless who_you_are submit, not to the path. POST /sign-up serves all 13 funnel steps, so one legitimate completion POSTs there more than ten times, and a hub edit of step 1 also submits an email. A path-scoped rule would 429 real alumni. See /debug → “Rack::Attack”.

Duplicate Prevention and Resolution (Phase 27.1, reversed by 28.1)

Specs: docs/planning/phases/phase-27/27.1-duplicate-prevention.md, docs/planning/phases/phase-28/28.1-email-uniqueness.md

Phase 28.1 inverted this section’s central claim. 27.1 said nothing prevents a duplicate at the source and that it was on purpose (Phase 27 Decision C) — prevention before the submit, resolution after, staff-side. An address now belongs to exactly one active signup, enforced by the database. Decision C is superseded: resolution did not scale to a verified credential, because Phase 28.2 signs people in on a Google identity and find_active_by_email was resolving collisions by taking the newest row.

#handle_who_you_are still decides create-vs-edit on the profile token alone, so a tokenless submit from a shared device can never overwrite someone else’s record.

The constraint.

CREATE UNIQUE INDEX index_champion_signups_on_lower_email_active
  ON champion_signups (LOWER(email)) WHERE deleted_at IS NULL;

LOWER(email) because find_active_by_email compares lowercased — an index on the raw column would report success while the finder still saw two rows. Scoped to active rows so a staff soft-delete returns the address to circulation, which is what removing a signup is for, and so the 15 historical duplicate sets stay readable. A model validation sits on top purely so the common case is a form error rather than a RecordNotUnique in front of an alum; the index is the authority and is what closes the race.

Prevention — the inline check. Email is the first field in step 1 as of 27.1, and the order is the feature: POST /sign-up/email-check fires on blur, before the person has invested any effort. A match offers a one-click “email me my link” that posts to the existing /profile-link and leaves the half-filled form intact.

This is an account-enumeration oracle by design (Decision D). What it discloses is program affiliation, not a credential, and there is no takeover path behind it — the lever is cost per query, not whether it leaks. Four things keep it that way, and none is optional:

What happens when someone submits anyway (28.1). The create fails, and the failure is handled rather than reported. #handle_who_you_are_failure mails the pre-existing record’s owner a link back into it — the same existing_signup_notice 27.2 already sent, except it used to arrive alongside a duplicate row and now arrives instead of one — and re-renders step 1 with the notice revealed and the person’s typing intact.

It is gated on the address being the only thing wrong with the submission:

if creating && @signup.errors.attribute_names == [:email]

That gate is load-bearing. Before 28.1, “the save succeeded” implicitly guaranteed a complete submission; the save never succeeds on this path now, so without the gate a minimal payload becomes an unauthenticated email trigger aimed at any address (27.2). A blank address also lands on :email and passes the gate, but resolves to no record.

The POST response now differs between a taken and an untaken address, which is a bit this endpoint did not previously leak. It is the same bit /sign-up/email-check answers by design, and POST /sign-up is throttled harder — 3 per 15 minutes per address against 10 per hour per IP.

Resolution — ChampionSignupMerger. Newest row wins, blanks fill from older rows, open text combines with date tags, sources soft-delete. One key as of 28.1:

Entry point Key Bulk? Reaches
merge_duplicates_for_buid BUID merge_all_duplicates! Rows a human has matched to an Alumni record

27.1’s merge_duplicates_for_email existed because BUID assignment is a manual staff action, so a signup with no matching Alumni row could not be merged at all. It came with ConflictingBuidsError, which refused a set carrying two distinct BUIDs — a shared family inbox and a typo produce the same shape as one person returning.

All of it was removed in 28.1, along with duplicates_by_email, has_duplicates_by_email?, duplicate_signups_by_email, the admin action and route, the show banner, and the amber badges and buttons on all. The unique index removed the population they served rather than the view of it: an address names at most one active row, so an email-keyed merge has nothing to merge and the conflict it refused cannot arise. The tests forced the deletion — every one described a state the database can no longer hold. ChampionSignupMerger now asserts that neither merge_duplicates_for_email nor merge_all_duplicates_by_email! responds, so the key cannot grow back quietly.

BUID is the only merge key and the only duplicate shape the app can produce — two different addresses on one BUID, which at 95% BUID coverage is ordinary.

The merger fills buid from sources as of 27.1. Under the BUID key that is a no-op, but it is kept: it was what stopped an email-keyed merge from soft-deleting the only row connected to an Alumni record, and a future second key would need it again.

Credentials follow a merge. merged_into_id points each soft-deleted source at the row that absorbed it, and all three resolvers go through ChampionSignup.follow_merge:

Resolver Was Is
find_by_access_code nil — a mailed mail-merge link died silently the surviving row
find_by_profile_token nil the surviving row
Signup::BaseController#session_signup session cleared, device went anonymous repointed at the surviving row

Resolution is always one hop. A→B then B→C re-points A at C during the second merge, so there is no chain to walk, no cycle to guard, and no depth cap to choose. A row soft-deleted by deletion has no merged_into_id and correctly still resolves to nothing — the fix must not resurrect deliberately removed records.

Admin surfacing. The status=duplicates filter, the sidebar badge and the show banner are BUID-keyed only. Merging is ensure_portal_admin!, same as every other merge.


⚠️ Retirement Notice (Phase 17.3)

The original public signup wizard at /signups/new was removed in Phase 17.3. Legacy URLs now 301 redirect to /champions. The v2.0 relaunch (Phase 19) is the active rebuild.

What Was Removed in Phase 17.3

What Is Still Active

Profile Pre-fill Field Mapping (to_champion_attributes)

ChampionSignup Field Cp::Champion Field Notes
first_name first_name  
last_name last_name  
maiden_name college_last_name Last name only — see the label note below
zip_code zip_code  
city city  
state state  
street or address street_address street preferred, falls back to address
phone phone  
google_uid google_uid Same column name and meaning on both sides (Phase 28.2) — a converted signup keeps its Google link instead of asking the person to connect an account they already connected
final_role_key primary_role Selected role (or quiz result)

All values are compact_blank — nil and empty strings are excluded.

maiden_name holds a last name only (August 2026). The field was labelled “Name while at Belmont”, which alumni read as “your whole name back then” and answered with first + last; the label now reads “Last name while at Belmont” with an e.g. Smith placeholder. ChampionSignupMaidenNameCleanup repairs what the old label collected — bin/rails champion_signups:cleanup_maiden_names (dry run; CONFIRM=1 applies) — across champion_signups and pending crm_data_changes rows.

The matching rule is narrow on purpose: a value is rewritten only when its first token is a first name already on file for that person (the signup’s own, the linked Alumni’s first_name / pref_name, or a nickname variation via Alumni.generate_name_variations). “Van Der Berg” matches nothing and is left alone, and anything multi-word it can’t confirm is reported for a human — a wrong guess here renames somebody. It also refuses to edit a CrmDataChange that is already exported or verified (Advancement Services holds that value), and skips a corrected row whose value the Alumni record already has, rather than exporting a non-change.


Historical documentation below — The following sections document the original system for reference.


Overview

The Champion Signup System provides a complete workflow for alumni to become “Alumni Champions” — volunteer ambassadors for Belmont University. The system spans two domains:

Domain Audience Features
belmontalum.com External Alumni Multi-step signup wizard
alumnilookup.com Internal Staff Management, review, exports

Table of Contents

  1. Public Signup Flow
  2. Champion Status & Prospect Management
  3. Staff Management Interface
  4. Email Notifications
  5. CSV Import/Export
  6. Duplicate Detection & Merging
  7. Data Model
  8. Technical Implementation

1. Public Signup Flow

Wizard Steps

The signup process at belmontalum.com guides alumni through 5 steps:

Step Status Value What’s Collected
1. Welcome started (1) First name, last name, graduation year
2. Personal Info completed_questions (2) Contact info, address
3. Questions selected_role (3) Quiz answers → role recommendation
4. Role Selection interests (4) Confirmed role choice
5. Interests zip_code (5) Lifestage interests, completion

Champion Roles

The quiz recommends one of four roles:

Role Description Icon
Connection Advisor 1:1 mentorship and career guidance 🤝
Digital Ambassador Social media and online engagement 📱
Community Builder Events and local gatherings 🏠
Giving Advocate Philanthropy and giving campaigns 💝

Completion

Upon completing step 5:


2. Champion Status & Prospect Management

Status Types

Status Criteria Visual Indicator
Completed the form ChampionSignup with status: 5 Blue border
Started, never finished ChampionSignup with status: 1-4v1 legacy only Yellow border
Manual Prospect prospect_status: 1 without a completed signup Sky blue border
No signup No signup, prospect_status: 0 Gray border

v2 signups are complete on creation. Signup::SignupsController#handle_who_you_are requires zip_code, and ChampionSignup#calculate_status returns 5 whenever a ZIP is present. So “started, never finished” can only ever describe a v1 wizard abandonment — useful for re-outreach, impossible to create today.

Prospect Management

Staff can manually flag alumni as prospects from the alumni show page:

Filtering Options (Phase 25.5)

The alumni search groups its filters into three fieldsets, and the grouping matters — Alumni Champions and Alumni Network are different surfaces and must not blur.

Alumni Champions

Alumni Network

Alumni Record

filter_by_alumni_status also understands all (the default when nothing is checked) and no_form. no_form is not offered in the UI — who we have not reached is a question for a later phase, and it returns essentially the whole alumni table. It stays in the scope so ALUMNI_FILTER_ALIASES has a target and legacy bookmarks resolve.

Retired values (completed_champions, champions_and_prospects, prospects, manual_prospects, signup_in_progress) map forward through Alumni::ALUMNI_FILTER_ALIASES, so bookmarked URLs keep working.


2b. Signup Data in the Lookup Portal (Phase 25)

Alumni::SignupProfile is the single read layer for everything the v2 flow collects. It is consumed by alumni#show, the search result row, AlumniFilterService and Csv::AlumniExporter — no consumer re-derives any of it.

Canonical signup: Alumni#current_champion_signup, the newest active row for the BUID. An alum can hold several; one of them speaks for them.

Effective location. The signup ZIP outranks alumni.zip for both display and district search; alumni.zip is never written. This is because a signup value is pending truthChampionSignupCrmLogger writes it to crm_data_changes, Advancement Services keys it into BruinQuest, and it flows back into alumni on the next sync. Divergence shows a “self-reported” badge and the CRM address beneath it; once the two ZIPs converge the badge disappears. Alumni::EFFECTIVE_ZIP_SQL is the correlated-subquery form used by filter_by_district.

Interest leaning replaces Champion Role. v2 dropped role selection for a four-area spectrum, so the profile shows top_interest_area + the full spectrum rather than a title. The seal artwork is reused unchanged — the area keys and the old role keys are the same four strings. No scorable quiz and no explicitly chosen role means no leaning, not a default one. List views (search results, CSV) read stored interest_scores only; the profile recomputes a v1 quiz from its raw answers.

Affinities are two sources, permanently. Nothing writes signup-reported groups into alumni_affinities — BruinQuest is the system of record and gets them through the export. filter_by_affinities matches either source.

On alumni#show both sources render as one list at equal weight, each entry carrying its detail line (2003 - 2004 • Webmaster) where it has one and a Source: BruinQuest / Source: Champion Signup line underneath. A BruinQuest row shows more because BruinQuest holds more, not because it outranks the alum. Only BruinQuest rows are editable. In search results, where there is no room for a source line, every chip carries the same solid border and the fill does the work: tinted background = BruinQuest, white = self-reported.

CSV. signup_city, signup_state, signup_zip, signup_district, location_source, top_interest_area, four *_pct columns, signup_affinities, signup_affinity_other. The pre-existing district and affinities columns keep their CRM-only meaning.


3. Staff Management Interface

Signup Admin surfaces (Phase 19.12)

Three pages, one job each — Dashboard = activity/triage, Insights = process rates, All Signups = segmentation.

Individual Signup View

Shows:

BUID Linking

Staff can link signups to alumni records by:


4. Email Notifications

Configuration

# Required environment variables
MAILGUN_API_KEY=your_api_key
MAILGUN_DOMAIN=mail.belmontalum.com   # fallback route only — see below
CHAMPION_ADMIN_EMAILS=alumni@belmont.edu,development@belmont.edu

# Optional — these are the code defaults, set only to override
ALUMNI_ENGAGEMENT_FROM="Belmont Alumni Engagement <alumni@mail.alumnichampions.com>"
OPPORTUNITY_NOTIFY_FROM="Belmont Alumni Engagement <noreply@mail.alumnichampions.com>"
MAILER_REPLY_TO="alumni@belmont.edu"

This system does not use MAILER_FROM_ADDRESS. That variable is the ApplicationMailer default, which covers Lookup Portal and Cp:: mail on mail.belmontalum.com. Signup mailers override it with their own addresses on mail.alumnichampions.com, so the product sends from its own domain rather than the portal’s.

MailgunDeliveryMethod routes each message through the Mailgun domain matching its own From address, which is what keeps DKIM’s d= aligned with the visible sender. MAILGUN_DOMAIN is the fallback for senders on no verified domain — not the universal route.

Two mailboxes, deliberately not interchangeable:

Address Used by Inbound
alumni@mail.alumnichampions.com welcome, profile link, admin notification forwarded to alumni@belmont.edu
noreply@mail.alumnichampions.com opportunity response notifications discarded

OpportunityMailer sends one message per recipient, each addressed to that person. It used to send a single message with the sender in To: and the list in bcc; Microsoft scored that shape SCL:5 / CAT:SPM on a live send even with SPF, DKIM and DMARC all passing. Per-recipient sends hide the list from its own members just as well. Its Reply-To is the team inbox rather than the responding alum, for the same reason — a freemail Reply-To on a different domain than the From is a phishing signature. The alum is reachable from a mailto: link in the body.

DNS records and Mailgun routes: docs/deployment/MAILGUN_DNS_SETUP.md.

Email Types

Welcome Email (to champion)

Admin Notification (to staff)

Profile Link (to champion) — profile_link_email

Already-Signed-Up Nudge (to champion) — existing_signup_notice, Phase 27.2

Testing

# Preview emails
http://localhost:3000/rails/mailers/champion_signup_mailer

# Send test emails
rails champion_signup:test_emails

5. CSV Import/Export

Import

Task: rails champion_signups:import[/path/to/file.csv]

Expected columns (case-insensitive):

Features:

Export

From /champion_signups index, click “Export CSV”.

Export includes:


6. Duplicate Detection & Merging

Detection

Duplicates are identified by matching BUID (when multiple signups are linked to the same alumni record).

Visual indicators:

Merging Logic

When duplicates are merged:

  1. Most recent signup becomes the target (by created_at)
  2. Older signups are soft deleted (deleted_at set)
  3. Data preservation rules:
    • Contact info: use most recent
    • Text fields: combine with date tags
    • Structured data: use most recent

Merging Tools

Web Interface:

Rake Tasks:

rails champion_signups:stats           # Statistics with duplicate counts
rails champion_signups:duplicates      # Detailed duplicate info
rails champion_signups:merge_all_duplicates  # Merge all
rails "champion_signups:merge_buid[B00123456]"  # Merge specific BUID

7. Data Model

ChampionSignup Table

create_table "champion_signups" do |t|
  t.string "first_name"
  t.string "last_name"
  t.string "graduation_year"
  t.string "email"
  t.string "phone"
  t.string "zip_code"
  t.string "street"
  t.string "city"
  t.string "state"
  t.string "interests"
  t.jsonb "answers", default: {}
  t.string "result_role"
  t.string "selected_role"
  t.string "lifestage_interest"
  t.jsonb "belonging_categories", default: []
  t.text "belonging_note"
  t.string "vocation"
  t.boolean "vocation_help", default: false
  t.string "buid"
  t.datetime "deleted_at"
  t.integer "status", default: 0, null: false
  t.timestamps
end

Status Enum

enum status: {
  started: 1,
  completed_questions: 2,
  selected_role: 3,
  interests: 4,
  zip_code: 5
}

Lifestage Interest Values

Valid keywords (comma-separated):

Alumni Association

# ChampionSignup
belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true

# Alumni
has_many :champion_signups, foreign_key: :buid, primary_key: :buid
enum prospect_status: { not_prospect: 0, prospect: 1 }

8. Technical Implementation

Controllers

Controller Domain Purpose
Champions::ChampionSignupsController External Public signup wizard
ChampionSignupsController Internal Staff management

Routes

# External (belmontalum.com)
constraints subdomain: 'champions' do
  scope module: 'champions' do
    resources :signups, controller: 'champion_signups'
    root to: "champion_signups#new"
  end
end

# Internal (alumnilookup.com)
resources :champion_signups do
  collection do
    get :export_csv
    get :duplicates
    post :merge_duplicates
    post :merge_all_duplicates
  end
end

Services

Service Purpose
ChampionSignupMerger Handles duplicate merging logic
Csv::ChampionSignupExporter Generates CSV exports

Mailers

Mailer Method Purpose
ChampionSignupMailer welcome_email Send to new champion
ChampionSignupMailer admin_notification Notify staff

Rake Tasks

rails champion_signups:stats                    # Show statistics
rails champion_signups:import[file.csv]         # Import from CSV
rails champion_signups:import_july_2025         # Import specific file
rails champion_signups:duplicates               # Show duplicate info
rails champion_signups:merge_all_duplicates     # Merge all duplicates

Data Cleanup

Lifestage Interest Normalization

Legacy data may have full descriptions instead of keywords. Cleanup tasks:

rails data:preview_lifestage_interest_cleanup   # Preview changes
rails data:cleanup_lifestage_interest           # Apply cleanup
rails data:cleanup_lifestage_interest_production  # Production-safe version

Mapping: | Legacy Format | Current Keyword | |—————|—————–| | “Almost Alumni (current seniors)” | almost-alumni | | “Young Alumni (0-10 years since graduating)” | young-alumni | | “Tower Society (50 or more years)” | tower-society | | “Belmont Family” or “Families” | families | | Unknown values | other |


Permanent Re-Entry Codes & Signup Insights (July 2026)

Permanent access codes — every signup carries a never-expiring access_code (unique, has_secure_token; ensure_access_code! backfills rows that predate the column). GET https://alumnichampions.com/profile/:code resolves the signup, adopts it into the session, and lands on the profile hub — the mail-merge path for inviting v1 (legacy) signups back to complete the v2 optional sections. Unknown/deleted codes fall back to the “email me a link” prompt. The staff CSV export includes access_code and a ready-to-merge profile_link column; filter the index by the v1 (legacy) source pill first to export exactly the legacy outreach list. Emailed profile links remain short-lived signed tokens — the permanent code is for staff-initiated outreach.

v1 → v2 alignment — no data migration: both generations share champion_signups. v1 contact info renders in the hub as-is; v1 quiz answers fall back to live interest- spectrum computation; v1-only fields (lifestage_interest, belonging_categories, vocation) remain visible to staff and in exports.

Signup Insights (/champion_signups/insights, staff) — v2 optional-section funnel, v1 legacy wizard funnel, return-visitor / link-request counts, v1-returned counter, and an initial-sitting vs. return-visit breakdown backed by champion_signup_events (ChampionSignupEvent.record, fire-and-forget; instrumented on signup creation, section saves, token/access-code return visits, and link requests).


Signup Admin Dashboard, Districts & Spam Guard (Phase 19.9 — July 2026)

Signup Admin area/champion_signups is now a sidebar-based dashboard (layouts/signup_admin, champion_signups/_sidebar): quick stats, recent signups, top districts, completion funnel, and a duplicates alert. The filterable list moved to /champion_signups/all (duplicates review = ?status=duplicates; the old standalone duplicates action was removed — it never had a template). The navbar dropdown section is now “Champion Signups” (Dashboard + Signup Insights); Alumni Network admin is reached via the sidebar’s “Alumni Network Admin” link. The list is mobile-optimized: card rows under sm:, primary filters (Status/Role/District) visible, the rest in a “More filters” disclosure.

Districtschampion_signups.district_id is assigned from zip_code on save (ZipCode.lookup, digit-stripped; self-heals nil districts; never overwrites v1 city/state) and was backfilled by migration. Location displays lead with “District — Region” (ChampionSignup#location_label falls back to city/state, then zip). The list filters by district via the shared district-autocomplete (/api/districts).

Spam guardSignupHoneypot protects the public step-1 and profile-link forms: an off-screen website field plus a signed minimum-time (3s) render timestamp. Tripped submissions get a silent fake success (no record, no email) and are logged with the [honeypot] tag for auditing.

Email signature — all three signup mailer emails render shared/email/_team_signature (“The Belmont Alumni Engagement Team”); the admin notification also shows the resolved District — Region as a Location row.


CRM Feedback Loop (Phase 22.3–22.6 — July–August 2026)

Until this shipped, an alum could update their address, employer and student groups through alumnichampions.com and none of it reached Advancement Services — the data landed in champion_signups and stopped. Signup submissions now feed the existing CrmDataChange pipeline and appear in the staff export at /settings/bruinquest/crm_changes.

ChampionSignupCrmLogger (app/services/) is the whole contract. It takes a ChampionSignup, resolves the linked Alumni by buid, and writes CrmDataChange rows with change_source: "champion_signup" — deliberately distinct from champion_portal (the belmontalum.com flow), because Advancement Services needs to know which product the alum used. It is called from all three public save points (who_you_are, affinities, where_you_are) and from staff assign_alumni.

Three rules govern everything it does:

  1. Linked signups only. No buid, no rows — CrmDataChange requires an identifier, and a change nobody can attach to a constituent is unusable.
  2. Absence is never an assertion. A blank field on a form is not the alum clearing it, so blanks are skipped everywhere.
  3. Only meaningful, un-logged changes. Same BUID + source table + field + value with a non-skipped status suppresses, so re-saving a section can’t double-log.
Category source_table Behavior
Contact alumni email / phone / city / state / maiden_name diffed directly; zip_code → the zip column; street has no alumni column and logs with a blank old_value. An email already on file under any of the five alumni email columns is suppressed.
Employment employment The reported employer (normalized via Employment.normalize_employer) is checked against every row in alumni.employments — an alum can hold more than one current position. No match: logs employer_name diffed against current_employment (blank old_value when there are no rows at all), with title and industry as notes. Match: the employer isn’t news, but a promotion is — falls through to a job_title diff against the position that matched. Identical title, or none reported, logs nothing.
Student orgs affinities Held (22.6). Logged from 22.4 until how an alum-reported group becomes a validated affinity code is decided. Nothing is written, and affinities is listed in CrmDataChange::EXPORT_HELD_SOURCE_TABLES so the rows 22.4 already wrote stay out of the staff page and the CSV. The reported codes are not lost — champion_signups.affinity_codes still holds them, which is what a re-enable would diff.

Identity-gated queue. ChampionSignup#needs_identity_match? (and the matching scope) is narrower than “not linked”: unlinked and actually holding reportable data. It surfaces as a dashboard callout, a needs_identity_match=1 filter and a row badge in Signup Admin, and an explainer on the signup detail page. assign_alumni runs the logger retroactively and reports how many held changes were queued — nothing collected before the match is lost.

Protection. champion_signup is one of CrmDataChange::PROTECTING_SOURCES, so a signup-reported value blocks the next BruinQuest import from overwriting that field until the import confirms it.

Export. Csv::CrmDataChangeExporter carries both buid and contact_id plus two columns the signup data made necessary: Source Table (field_name alone no longer identifies the target now that employer_name exists) and Champion Signup ID (traceability, mirroring cp_champion_id for the portal).

Grouped rows (22.6). The file is no longer one row per CrmDataChange. It is one row per constituent per target record, with a named column for every field — so an alum who changed their name, email and address is one line to open and one edit to make, not six rows to reassemble.

Label (Change Type) Columns source_table
Name First Name, Pref Name, Maiden Name, Last Name alumni
Contact Email, Email School, Email Personal, Email Business, Email Other, Phone alumni
Address Street, City, State, Zip alumni
Employment Employer Name, Job Title employment

The first three share a row; Change Type lists what it holds (Name, Contact, Address). Employment is the one thing that separates out, and only because alumni.employments is one-to-many: an employer change targets a different record than a city change, and an alum with two pending positions would collide on a single Employer Name column. Rows group by BUID + source_table, which is exactly that distinction.

Three rules make the file safe to read literally:

FIELD_GROUPS is the single source of truth — headers, column order and the Change Type labels are all derived from it, so a field cannot get a column without a header. The staff page at /settings/bruinquest/crm_changes is unchanged: it stays one row per change, because it is a work queue, not a mapping file.


Future Development

The Champion Portal (planning/) will expand this system with:

When implemented, the signup flow will integrate with the new portal, and champions will gain access to authenticated features beyond the initial signup.