Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/alumni-network/source/README.md.
Use these sources (includingCHRIST_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
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::ChampionSignupsControllerin apublic/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.
| 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 |
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.
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.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).affinities — full affinity browse/search (affinity_codes + affinity_other + belonging_note); returns to the hub.where_you_are — ZIP, 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.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. question1–question7 → quiz_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.
signup_token fallback so role selection/skip can still complete if session state is missing mid-flow.signup_token belongs to the current signup.source, college/major/industry/job, affinity data) with raw-code fallback for unknown affinities.signup_role_options_controller.js (the old “I already know” reveal) was deleted.ChampionQuizService.role_counts/generate_results/calculate_primary_role untouched); the spectrum is built additively via ChampionQuizService.interest_spectrum.champion_signups.interest_scores (jsonb, default {}) stores each area’s percentage (0–100); result_role is still persisted as the top-scoring area for backward compatibility.ChampionRoleService.area_label.answers when interest_scores is blank, so quiz-takers from before the backfill still see a full breakdown.interest_scores/the spectrum are v2-only — legacy pre-19.1 records store answers as {"q0" => "word"} rather than quiz letters, so they’re excluded from scoring, the backfill task (champion_signups:backfill_interest_scores[dry_run]), and the staff area filter.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.
ChampionEducationService.article_for picks a/an by how the code is spoken:
all-caps codes read letter by letter (“an MBA”, “a BBA”), mixed case by
spelling (“a PhD”, “an EdD”).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:
last_return_visit_at — reached the hub via an emailed token or the permanent
access code (they opened the link)last_enriched_at — saved an optional section more than
ChampionSignupEvent::RETURNING_THRESHOLD (1 hour) after signing up (they
actually told us something new)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.
Three defects found while testing the v1 re-outreach links, all on the public flow:
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.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.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:
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.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.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 is complete (19.1–19.8 shipped); 19.10 and 19.11 shipped as follow-ups.
belmont_background (text) — free-text college(s)/major(s) verification inputcompany (string) — current employer/company for profession stepcurrent_season_note (text) — free-text shown only when “I’m not currently working” is selected on the where_you_are step; cleared when user switches to employed/seekingAdded to champion_signups:
college_code — Belmont college code (FK ref to colleges)major_code — Belmont major code (FK ref to majors)industry — current industryjob_title — current role/titleaffinity_codes (jsonb, default []) — selected affinity codesaffinity_other — free-text for affinities not in the listsource — e.g. "champion_signup_v2" to distinguish from legacy recordsGET alumnichampions.com/ → Signup::SignupsController#new (landing)GET alumnichampions.com/sign-up → Signup::SignupsController#newPOST alumnichampions.com/sign-up → Signup::SignupsController#createGET alumnichampions.com/sign-up/:id → Signup::SignupsController#showGET alumnichampions.com/sign-in → Signup::SessionsController#new (Phase 27.2)belmontalum.com/sign-up → 404 (nested domain constraint blocks it)See full spec: docs/planning/champion-flow/SPEC.md
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.
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 | |
|---|---|---|
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:
issue! a code, only hide it. issue! supersedes any
outstanding code, so a link request from step 1 would silently kill a code the same
person is part-way through typing in another tab.link_sent (which reveals the code box) reads from the same predicate the email
does. That is what stops the page offering a box for a code that was never sent.entry_point returning. That param asked which page rendered the
form — cosmetic, it picked a redirect target. mode asks what the recipient is
expecting, which decides what gets sent. The 27.8 consolidation was still right; it
simply collapsed the wrong axis along with the right one.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 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:
where_you_are; the “Your details” card’s
email gained a separate “Change” link, since email and location are now on different
steps.assign_district_from_zip fires one step later, so a signup abandoned after step 1
has no district where it previously would have had one. That is correct — an abandoned
signup genuinely has no location — but Signup Admin will show slightly fewer districted
rows among incomplete signups.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.
POST /auth/signup_google → Google → GET /auth/signup_google/callback →
Signup::OmniauthCallbacksController#google → Signup::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:
email_verified is a hard gate. Neither Cp::Champion.from_omniauth nor
User.from_google_oauth checks it — on the staff surface admins create accounts by hand
from known addresses, so the risk is low. This is a public funnel where auto-linking is
the entire feature, so an unverified address matches nothing and is not even prefilled.
Absent is false. The uid still attaches on create; it is Google’s account identifier
and is trustworthy whatever the address does.belmont.edu. Belmont runs Microsoft, so those addresses
have no Google identity to match. Their owners click with a personal account, and the BUID
fallback is what connects it to the record they already have. Without it they get a signup
form and create the duplicate 28.1 just made impossible to hold.google_uid stays put and the person is told which account holds it. Only the ambiguous
case refuses: the uid naming one row while the verified email names another, where there
is no single record to open — and that message never says whose record it is, because a
Google login must not become an identity oracle.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.
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.
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:
ChampionSignup.follow_merge — the fourth credential to do so.ChampionSignup#soft_delete!. Each row holds a user
agent and an IP, so keeping them is retaining PII after a deletion request.
dependent: :destroy alone is not enough: the app soft-deletes, so it never fires.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.
Montserrat is self-hosted — app/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 ratchet
— ALLOWED_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.
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”.
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:
min_seconds: 0 — the check keeps the website trap and the forged-form_ts trap but
drops the 3-second clock, which would eat a fast typist’s first checkfail2ban/throttle-abuse escalating repeat offenders{"known": true|false} and nothing else — no name, no masked
email, no idWhat 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.
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.
Champions::ChampionSignupsController)ChampionSignupMailer (welcome + admin notification emails; being restored in Phase 19.4)alumnilookup.com/champion_signups — index, show, BUID assignment, duplicate merging, CSV exportCp::ConfirmationsController#find_legacy_signup_for matches signups by email during account creation and pre-fills Cp::Champion profiles via ChampionSignup#to_champion_attributesto_champion_attributes mappingEngagementStats::OverviewService counts completed signups by BUIDto_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.
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 |
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 |
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 | 💝 |
Upon completing step 5:
| Status | Criteria | Visual Indicator |
|---|---|---|
| Completed the form | ChampionSignup with status: 5 |
Blue border |
| Started, never finished | ChampionSignup with status: 1-4 — v1 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_arerequireszip_code, andChampionSignup#calculate_statusreturns5whenever a ZIP is present. So “started, never finished” can only ever describe a v1 wizard abandonment — useful for re-outreach, impossible to create today.
Staff can manually flag alumni as prospects from the alumni show page:
prospect_status is a staff judgment about someone who has not filled out the
form. It is deliberately independent of form completion (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_filter=completed_form) — has a completed (status 5) active signupmanual_prospect=1) — prospect_status: 1, a staff
judgment, deliberately independent of form completionAlumni Network
has_network_account=1) — a Cp::Champion on belmontalum.comAlumni Record
current_student=1), Has engagement activity (has_engagement=1)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.
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 truth — ChampionSignupCrmLogger 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.
Three pages, one job each — Dashboard = activity/triage, Insights = process rates, All Signups = segmentation.
/champion_signups) — clickable health strip (Total, New this week w/ WoW delta, % linked, Needs attention), an activity feed naming who signed up / re-engaged and what they touched, and Top Districts + Top Groups (both link into the filtered List)./champion_signups/all) — columns Person / Last activity / Location / Interests-&-Groups / View; a color-coded “Leans: [area]” chip (stored or computed from quiz answers, so v1 too); Sort (recently-active / newest / name / rarest-group); filters for District, Leans, Group, Group size (niche ≤5 / movement ≥15), Return activity, Status, plus Not-linked / Contactable toggles; Merge duplicates; CSV export./champion_signups/:id) — see below.Shows:
Staff can link signups to alumni records by:
# 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.
Welcome Email (to champion)
Admin Notification (to staff)
Profile Link (to champion) — profile_link_email
/sign-in (the only page with the form as of 27.8)return_to. Pinned as an order in both MIME parts — a test asserting both are present passes on either arrangementAlready-Signed-Up Nudge (to champion) — existing_signup_notice, Phase 27.2
signup-creates/email (3 per 15 min)# Preview emails
http://localhost:3000/rails/mailers/champion_signup_mailer
# Send test emails
rails champion_signup:test_emails
Task: rails champion_signups:import[/path/to/file.csv]
Expected columns (case-insensitive):
Features:
From /champion_signups index, click “Export CSV”.
Export includes:
Duplicates are identified by matching BUID (when multiple signups are linked to the same alumni record).
Visual indicators:
When duplicates are merged:
created_at)deleted_at set)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
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
enum status: {
started: 1,
completed_questions: 2,
selected_role: 3,
interests: 4,
zip_code: 5
}
Valid keywords (comma-separated):
almost-alumni — Current seniorsyoung-alumni — 0-10 years since graduatingtower-society — 50+ years since graduatingfamilies — Alumni with young childrenother — Any other interests# 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 }
| Controller | Domain | Purpose |
|---|---|---|
Champions::ChampionSignupsController |
External | Public signup wizard |
ChampionSignupsController |
Internal | Staff management |
# 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
| Service | Purpose |
|---|---|
ChampionSignupMerger |
Handles duplicate merging logic |
Csv::ChampionSignupExporter |
Generates CSV exports |
| Mailer | Method | Purpose |
|---|---|---|
ChampionSignupMailer |
welcome_email |
Send to new champion |
ChampionSignupMailer |
admin_notification |
Notify staff |
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
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 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 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.
Districts — champion_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 guard — SignupHoneypot 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.
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:
buid, no rows — CrmDataChange requires an
identifier, and a change nobody can attach to a constituent is unusable.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:
Zip means the zip was not
reported, not that it is empty. No column ever carries a value inferred from the
alumni record.Previous Values, which spells out
every field’s old → new so the row stays auditable at its original grain.Field Name / New Value
pair — log_manual_change accepts any field name, and two of them on one row
would leave the second with nowhere to put its value.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.
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.