All notable changes to the Alumni Lookup application are documented here.
The format follows Keep a Changelog.
Supersedes 1.0.88, which was tagged from a commit whose CI build failed and therefore never reached production. Same contents, plus the test fix. The superseded tag is left in place rather than moved: a force-updated tag is not guaranteed to re-trigger a deploy, which is the one thing this release needs to do.
Phase 28 in full — Google sign-in on the signup surface, one signup per email address, and an identity-only first step — plus Phase 21.9.
Two migrations, both manual (there is no
release:phase in the Procfile):heroku run rails db:migrate --app alumni-lookup heroku run bin/rails champion_signups:recalculate_status CONFIRM=1 --app alumni-lookupProduction collision count verified 0 before release, which is what the 28.1 index requires. Google redirect URIs must be registered at
/auth/signup_google/callback— notgoogle_oauth2— andGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETmust be present, or the button silently does not render.
No migration. One rake task, to be run after this deploys:
bin/rails champion_signups:recalculate_status CONFIRM=1(dry-run without the flag). Spec:docs/planning/phases/phase-28/28.3-step-one-and-prefill.md.
One migration (
AddGoogleIdentityToChampionSignups) —google_uidandgoogle_linked_at, both nullable, plus a unique partial index. Additive and reversible. Requires 28.1 deployed first. External step, blocking: an authorized redirect URI athttps://<host>/auth/signup_google/callbackfor every signup host. Note the path —signup_google, notgoogle_oauth2. Spec:docs/planning/phases/phase-28/28.2-google-sign-in.md.
/sign-up and /sign-in. Both
buttons post to one endpoint and run one resolver, so which door you came
through decides nothing — the destination follows what we find. Click it on the
signup page with a record already on file and you are signed in; click it on the
sign-in page without one and you get the signup form.belmont.edu population this
is not an improvement to the match rate — it is the only path there is.PRIVACY_POLICY_VERSION bumped to 2026-08-14.
Two disclosures: a Google account can become connected without an explicit
connect action, and identity may be resolved through an address you did not
present. Rows stamped 2026-08-12 accepted neither.signin_completed gains method: "google" (no new type — a Google
sign-in is a sign-in), plus new google_linked and google_unlinked.
to_champion_attributes carries google_uid, so a converted signup keeps its
link.One migration (
AddUniqueIndexOnChampionSignupEmail) — a partial unique index onLOWER(email) WHERE deleted_at IS NULL, builtCONCURRENTLY. It refuses to run while any active address is held by two rows, and names them; it will not merge them for you. Resolve them in Signup Admin → duplicates on the current release first. Spec:docs/planning/phases/phase-28/28.1-email-uniqueness.md.
find_active_by_email resolved collisions by taking
the newest row. That is a fair guess under a mailed link, which only ever
reaches one inbox; it is not a fair basis for signing somebody in on a verified
credential, which is what 28.2 adds. The guess is retired rather than hardened.One migration (
RemoveWhenNoteFromOpportunities) — drops the column 21.8 added one release earlier. It is a column drop, so old dynos calling it during the release window raise; the window is seconds and the column is one release old. Spec:docs/planning/phases/phase-21/21.9-opportunity-event-styling.md.
Tagged, then abandoned: the CI build for the tagged commit failed on a test that
passes locally and fails in CI (Devise.omniauth_configs is fixed at Rails boot
from GOOGLE_CLIENT_ID, which CI does not set — see
.claude/skills/debug/SKILL.md). Nothing reached production under this
version. Its contents ship as 1.0.89 with the test fix; the tag is left in
place rather than moved, because a force-updated tag may not re-trigger a deploy.
No application code differs between the two.
One migration (
AddEventDetailsToOpportunities) — six nullable columns and an index. Every existing opportunity is undated, so the new expiry behavior deploys as a no-op. Heroku deploys via GitHub Actions run migrations automatically (deploy-production.yml); a directgit push herokudoes not. Spec:docs/planning/phases/phase-21/21.8-opportunity-event-details.md.One manual step after deploy — no automated backfill. The Almost Alumni Breakfast opportunity has its When / Where / The ask typed into the intro copy; staff move those lines into the new fields and delete them from the intro, or the page shows both. See
docs/planning/qa/PHASE_21_LAUNCH_GUIDE.md§4.
When: /
Where: / The ask: lines at the top of the intro copy, where they were formatted
differently on every form, appeared nowhere except the form itself, and could not be
sorted or expired by. “When” takes a start, an optional end, and a free-text override
for the cases a date can’t express (“Various dates through the fall”). A blank field
is left off the page entirely — no empty labels, no “TBD”.Scope grew before this shipped. The version section was written on August 6 for the mail-domain change alone. It was never tagged, and all eight sub-phases of Phase 27 landed in the meantime — so this release is both. Phase 27 first; the mail-domain notes follow unchanged.
Three migrations. Heroku deploys via GitHub Actions run them automatically (
deploy-production.yml), but a directgit push herokudoes not — there is norelease:phase in theProcfile.
Follow-on to 27.7, written against the shipped flow rather than a spec. No migration, no new route, no policy change. Spec:
docs/planning/phases/phase-27/27.8-one-front-door.md.Three of its four items were the same defect wearing different clothes — a second copy of a decision, each of which had drifted within one sub-phase of being created. See §7 of the spec, and the “Link first, share second, copy never” rule now in
/patterns.
/sign-in. The landing page and the opportunity
thank-you page each carried their own copy of the “email me a link” form. Both are
links to the sign-in page now, so there is one form, one confirmation state, and one
code box — the copies had already drifted, having neither the code box nor the
“keep me signed in” checkbox that were added to the real one.%>, so an escaped example tag inside one closed it early
and leaked the rest. Every view in the app is now scanned for this on every build.Migration:
create_champion_signup_signin_codes. Reverses Phase 27 Decision B. Privacy policy version bumped to2026-08-12— the remembered-device cookie changed meaning, so anyone who ticked the box before that date consented to something weaker. Spec:docs/planning/phases/phase-27/27.7-one-signed-in-state.md.
No migration. Specs:
docs/planning/phases/phase-27/27.4-opportunity-flow.md,27.5-hub-additions.md. Phase 27 is complete — all six sub-phases shipped, and all four of its open questions are closed.
anonymous / recognized /
verified)."FridaySaturday" with no separator, and a box with nothing ticked
printed an empty row. Found while extracting the receipt into a partial the profile hub
now shares.Migration:
create_champion_signup_sessions. New recurring task:champion_signup_sessions:prune(schedule daily — it is the retention rule). Spec:docs/planning/phases/phase-27/27.3-remembered-devices.md.Shipped as one release deliberately: Phase 27 §5 requires the compliance half not lag the cookie, and the coupling is now asserted in tests rather than trusted to a checklist.
/sign-in
sets an optional cookie backed by a new champion_signup_sessions row. That checkbox is
the consent — no cookie banner, because there is no banner-worthy category: no analytics,
no pixels, no ad tech, no sale or sharing.j••••@example.com) and nothing else — no hub access, no edits, no
unmasked contact details, and a permanent “Not you?”. That asymmetry is what makes a
6-month cookie safe on a shared or kiosk computer, so the tests that guard it are the
negative ones.Signup::Identity now owns the anonymous / recognized / verified decision for the
whole surface, resolving explicit credentials ahead of ambient ones (profile token →
session → device cookie). It delegates session resolution to the one place that already
owned it rather than re-deriving it.follow_merge, after the access code, the profile token, and the session.signin_completed, device_remembered, device_revoked.fonts.googleapis.com and fonts.gstatic.com — no cookie, but the IP
transmission is the fact pattern behind the January 2022 Munich ruling. Ten woff2 subsets
with their unicode-range declarations preserved, so a Latin-only visitor still
downloads exactly two files. OFL license included.2026-08-08. It now discloses the
second cookie and its duration, explains that being recognized is not being signed in,
states that we do not sell or share personal information (the documented GPC posture —
no no-op handler), and discloses the email-check enumeration trade-off rather than
leaving an oracle sitting silently under the policy’s no-tracking claims.champion_signup_sessions:prune deletes expired
rows; expired rows are already inert before it runs. Deleting a signup now destroys its
device sessions rather than leaving rows holding a user agent and an IP.signup, application); the other three
active layouts load Material Icons, a different Google font this phase did not scope./sign-up
eagerly fetches five JavaScript modules from cdn.jsdelivr.net on every load —
flowbite, trix, chart.js, chart.js/auto, @kurkle/color, pinned in
config/importmap.rb and emitted as <link rel="modulepreload">. Beyond the IP
disclosure, that is third-party JavaScript executing on the page where alumni type their
contact details.Migration:
add_merged_into_id_to_champion_signups. Spec:docs/planning/phases/phase-27/27.1-duplicate-prevention.md. Ships after 27.2, whose throttles meter the check endpoint below.
POST /sign-up/email-check, and a match
offers a one-click “email me my link” that sends through the existing /profile-link
and leaves the half-filled form intact. Nothing blocks — submitting anyway still creates
the row, exactly as before (Phase 27 Decision C), with 27.2’s nudge email as the
backstop. The endpoint is an enumeration oracle by design: gated on the form’s honeypot,
metered at 10/hr/IP, and answering {"known": true|false} and nothing else.ChampionSignupMerger.merge_duplicates_for_email. Both existing merge entry points
required a non-blank BUID, and BUID assignment is a manual staff action — so a signup
with no matching Alumni row could never be merged at all. The email key reaches it.
Merging fills a blank buid from the sources, which the merger never did before; under
the BUID key that was a no-op, but under the email key it would have soft-deleted the
only row linked to an Alumni record.duplicates_by_email fixed before it drove any UI. It grouped on the raw email
column with no blank exclusion — case-sensitive, and every blank-email row collapsed into
one enormous false duplicate set. Harmless while nothing called it.show
banner, and the status=duplicates filter, alongside the BUID equivalents.ChampionSignupMerger
soft-deletes its sources and every credential resolver scoped to active, so a permanent
access code already sent in mail-merge outreach stopped resolving the moment staff merged
that row — no redirect, no notice. Access code, profile token, and device session all
follow merged_into_id to the surviving record. A row soft-deleted by deletion still
resolves to nothing.signup_merged activity event, recorded against the surviving row with the merged ids,
the key used, and who merged.No migration. Spec:
docs/planning/phases/phase-27/27.2-signin-front-door.md. 27.2 ships before 27.1 — 27.1’s inline email check is an enumeration oracle that Decision D requires be throttled, and every throttle it needs is in this sub-phase.
GET /sign-in — a dedicated sign-in page. The app has done passwordless
authentication since 19.7, but layouts/signup had no navigation, so the “email me a
link” form was reachable only by scrolling the landing page. A returning alum on a new
device had no way in. The page wraps the existing POST /profile-link flow unchanged
and stays enumeration-neutral: identical response for known and unknown addresses.show was the only
entry point that never established a session — the just-signed-up path and the
access-code path both did — so a magic-link visitor was carrying a credential in the
URL rather than being signed in. The hub threaded that token through its own links, so
the hub worked and everywhere else on alumnichampions.com made you anonymous again. A
72-hour profile token now grants the same 2-week session a never-expiring access code
always has.signin_link_sent activity event. link_requested is unchanged and still records the
landing-page form, so existing rows keep their meaning.Two defects found by the first test ever written against a throttle in this repo. Both predate Phase 27 and were invisible from outside, because an abusive request still got refused — just not the way the code was written to.
throttled_responder and blocklisted_responder were written as ->(env), but
rack-attack 6.0 passes a Rack::Attack::Request, which has no #[]. Consequences: no
Retry-After header was ever sent (so well-behaved clients retried immediately), the
responders’ own log lines never ran, and every rate-limit trip was recorded as an
application exception.fail2ban/throttle-abuse never banned anyone. It incremented its counter from
inside the blocklist, reading rack.attack.matched — a key no throttle has written at
that point, because Rack::Attack evaluates blocklists before throttles. The blocklist
now only reads the ban flag; the counter is incremented from the throttle.rack_attack
subscriber./profile-link is metered for the first time. It sends a magic link to any address
on request and had nothing in front of it but the global 300/5min and the honeypot — an
unmetered email-bomb vector. Now capped per IP and per address; the per-address cap
is the one that matters, since a per-IP cap alone still lets one attacker spray many
addresses.who_you_are submit rather than to POST
/sign-up, because that path serves all 13 funnel steps: a flat per-path rule would
have 429’d the first alum to complete a signup, and worst for the ones who answer
every question.champion-logins/ip, champion-signups/ip, champion-signups/email renamed to
portal-*. They guard the frozen belmontalum.com portal’s Devise routes (/login,
/signup — no hyphen), not the live signup ecosystem their old names implied. Phase
27’s spec originally called them “stranded” and proposed removing or repointing them;
both would have stripped rate limiting off a reachable production login. Renamed in
place, with the surface named in a comment.signup-email-check/ip (10/hour) ships ahead of its endpoint, so 27.1’s
enumeration oracle is never live unmetered. Inert until that route exists./sign-in rather than the landing page — the “get a fresh link” message
belongs above the form that issues one.Deploy step required before this ships:
mail.alumnichampions.commust be verified in Mailgun with SPF, DKIM and MX records, plus two Mailgun routes (alumni@forwards toalumni@belmont.edu,noreply@discards). Full record-by-record instructions are indocs/deployment/MAILGUN_DNS_SETUP.md. No env vars need setting — the code defaults are the intended production values. No migration.
Champion Signup email now comes from mail.alumnichampions.com, not
mail.belmontalum.com. Every signup email — welcome, profile link, admin
notification, opportunity response — was sending from the portal’s domain, so mail
from a product at alumnichampions.com arrived branded as belmontalum.com. Alum-facing
mail sends from alumni@mail.alumnichampions.com; opportunity notifications from
noreply@mail.alumnichampions.com. Reply-To is unchanged at alumni@belmont.edu.
MailgunDeliveryMethod now picks a sending domain per message, matching the
message’s own From address against MAILGUN_SENDING_DOMAINS. Changing only the From
header would have been worse than leaving it alone: a single global MAILGUN_DOMAIN
routed everything through belmontalum, so alumnichampions mail would have gone out
signed d=mail.belmontalum.com — unaligned on both SPF and DKIM, and a DMARC failure
under any policy stricter than p=none. MAILGUN_DOMAIN is now the fallback for
unrecognized senders rather than the universal route, so portal and Lookup mail are
untouched.
The two alumnichampions mailboxes are not interchangeable. Alum-facing signup mail
uses the forwarded alumni@; the staff- and partner-facing opportunity notifications
use the discarded noreply@. Tests assert the two never converge.
Verified against a live staging send (Aug 6, 2026): SPF, DKIM and DMARC all passed with
an aligned d=, and Microsoft still scored the message SCL:5 / SFV:SPM / CAT:SPM.
Authentication proves identity; it does not stop a message being filtered on shape.
One message per recipient, addressed to that recipient. These were sent as a single
message with the sender in To: and the whole notification list in bcc — the shape of
bulk mail, and the strongest spam signal in the message. Per-recipient sends hide the
list from its own members just as well, which was the only reason for bcc. To: is no
longer the From address, so the sending mailbox is no longer a delivery target.
Reply-To is the team inbox, not the responding alum. It was set to the alum’s own
address so staff could hit Reply; that address is usually freemail, and a freemail
Reply-To on a domain other than the From is a phishing signature. The alum is now one
click away via a mailto: button in the body instead.
A failed send to one recipient no longer costs the rest their notification — the
rescue in NotifyOpportunityResponseJob moved inside the per-recipient loop, where
there is now something to salvage.
MailgunDeliveryMethod constructed its client as Mailgun::Client.new(api_key, api_host,
'v3', false). The fourth positional argument is ssl, not a debug flag: it built an
http:// endpoint and set ssl.verify false, so the API key went out as HTTP Basic auth
unencrypted on every send. Now true. Pre-existing; unrelated to the domain change.
One migration, run automatically by the deploy workflow (
deploy-staging.ymlanddeploy-production.ymlboth runrails db:migrate). Addsopportunities.listed(boolean, defaulttrue) andopportunities.notify_emails(text, nullable), plus a partial index on(active, listed). Additive and backfill-safe — every existing opportunity keeps today’s behavior, and there is no data step to run by hand.Nothing else in this release needs running: no rake task, no backfill.
Checkbox questions. A sixth field type for “pick any that apply”, where the only previous options were a dropdown that lost information or a long-text field nobody could filter on. Available on every opportunity; options are validated server-side against the list the CMS authored, same as a dropdown. Answers export as one semicolon-joined CSV cell.
Link-only opportunities. Listed and Link only are now separate from on/off.
Until now, making a form’s link work was the same switch as advertising it on every
returning alum’s profile page, so a form for just the Alumni Board or just one
newsletter was impossible. A link-only opportunity is fully live and serves its
direct link; it simply isn’t listed anywhere. Active still governs reachability —
switching an opportunity off 404s the link for everyone, listed or not.
Per-opportunity response notification emails. Each opportunity carries its own comma-separated recipient list, so the person who actually owns a form gets the response whether or not they have a Lookup Portal account. The email includes the submitted answers, contact details, and — when the response links to a Champion record — graduation year, location, affinities, industry and job title, plus the matched alum’s degrees, BUID and BQID. Recipients are BCC’d; replies go to the submitter.
A match made on email address alone is labeled unconfirmed in the email itself. Shared inboxes, couples and typos all produce email matches, and a guess must never read to staff as a confirmed identity.
Recipients without a Lookup Portal account don’t get the “open in the staff queue” link, which would only land them on a login screen. Delivery splits by audience when a list contains both; a list that is all one or all the other stays a single email.
Blank recipient list is the default and sends nothing, which is exactly how every opportunity behaved before this release. Phase 21.4 deliberately shipped in-app and push notifications only, because a widely shared opportunity link must never flood an inbox; making email opt-in per form preserves that. Admins still get their in-app notification either way, and a mail failure cannot cost staff their queue badge.
Print / Save as PDF for a single response. Signup Admin → Responses → open one → Print / PDF opens a print-styled page; save it from the browser to send a response on to someone else. Built live off the current record rather than archiving what was emailed, so a response linked to a Champion after the notification went out prints with the record we hold today. Carries the unconfirmed-match caveat and a print date, so a forwarded copy keeps its context. No PDF library added — a print stylesheet avoids a new dependency on a stack with known memory limits.
Trimmed “What we already know” in the notification email to what staff act on: graduation year, industry, job title, employer, interests, affinities, and degrees. Location and district came out because the contact block directly above already answers it from what the alum just typed; BUID, BQID and the signup date came out as reconciliation data. The CSV export still carries both ids.
A required checkbox with nothing ticked could be submitted. Found while building
the field type above, before it shipped. Answer blank-checking ran through
value.to_s.strip.blank?, and [].to_s is "[]" — not blank — so an empty
multi-select passed validation. Blank checks are now array-aware.
Long questions rendered off the edge of the notification email. The answers block
used a two-column table whose label cell was set white-space: nowrap — correct for
short labels like “Email”, wrong for a staff-authored question running 60–100
characters, which pinned the table wider than the email container and pushed every
answer out of view. Questions and answers are now stacked, and essay answers keep
their paragraph breaks.
Post-deployment: run the maiden-name cleanup — dry run first, and read it.
heroku run bin/rails champion_signups:cleanup_maiden_names --app alumni-lookup heroku run bin/rails champion_signups:cleanup_maiden_names CONFIRM=1 --app alumni-lookupThe dry run’s NEEDS REVIEW section is the part no task should decide — values it could not confirm as first-name-plus-surname, and any
exported/verifiedCRM row Advancement Services may already hold with the extra word. Nothing else in this release needs running: no migration, no schema change.
Signup form: “Name while at Belmont” → “Last name while at Belmont.” Alumni read
the old label as “your whole name back then” and answered it with first + last, so
champion_signups.maiden_name collected values like "Kate Whitfield" — and, for
linked signups, handed them to Advancement Services as two-word maiden names.
Field now carries an e.g. Smith placeholder as well.
Cleanup for what the old label collected.
bin/rails champion_signups:cleanup_maiden_names (dry run; CONFIRM=1 to apply)
strips the first name from champion_signups.maiden_name and from pending
crm_data_changes rows. The rule is deliberately narrow: a value is rewritten only
when its first token is a first name we already hold for that person — the
signup’s own, the linked Alumni’s first_name or pref_name, or a nickname
variation of any of them (Alumni.generate_name_variations, so a Katherine who
typed “Kate Whitfield” is caught). Genuinely multi-word surnames — “Van Der Berg”,
“De La Cruz” — match nothing and are left alone; anything multi-word it can’t
confirm is reported for a human rather than guessed at, because a wrong guess
here renames somebody.
Two refusals are built in: it never edits a CrmDataChange that is already
exported or verified (Advancement Services holds that value; ours must not
silently diverge — those are listed instead), and when the corrected maiden name is
what the Alumni record already has, the row is skipped with a reason rather than
exported as a change it no longer is.
Cp::Champion#college_last_name — the portal’s equivalent field — is out of scope;
that surface is on hold.
Flaky test: “an instant profile-link submit is still treated as spam.” A real
race, not CI noise. SignupHoneypot compares integer-truncated epoch seconds and
SHORT_FORM_MIN_SECONDS is 1, so “instant” (0) and “one second later” (1) are
adjacent integers with zero slack — a 2ms gap that happens to straddle a
second boundary makes the difference 1, 1 < 1 false, and the submission is not
spam. Now wrapped in freeze_time. The 6-field form’s equivalent test survives
only because MIN_SECONDS is 3; lowering that constant would make it just as
fragile.
CRM export: one row per constituent per target record.
Csv::CrmDataChangeExporter is no longer one row per CrmDataChange. Every field
now has a named column, and everything that updates the alumni record — name
(first_name/pref_name/maiden_name/last_name), contact (all five email
columns + phone) and address (street/city/state/zip) — arrives on one
line. An alum who changed their name, email and address is one record to open and
one edit to make, not six rows to reassemble. A new Change Type column lists what
the row holds (Name, Contact, Address).
Employment stays on its own row, and only for a real reason:
alumni.employments is one-to-many, so 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.
Only fields that actually changed are filled in — a blank Zip means the zip was
not reported, not that it is empty. No column carries a value inferred from the
alumni record. The old per-field Old Value is replaced by one Previous Values
column spelling out each field’s old → new, so a grouped row is still auditable
at its original grain. A field occupies one slot per row: a city corrected twice
before anyone exported gets a second row rather than losing a change. An ad-hoc
field (log_manual_change accepts any name) keeps its own row and the generic
Field Name / New Value pair. Headers, column order and the Change Type labels
are all derived from FIELD_GROUPS, so a field cannot get a column without a
header.
The staff page at /settings/bruinquest/crm_changes is deliberately unchanged —
it is a work queue, not a mapping file.
Student groups pulled out of the CRM pipeline. How an alum-reported group
becomes a validated affinity code is still undecided, so ChampionSignupCrmLogger
no longer writes affinity_code / affinity_other rows, and affinities is
listed in the new CrmDataChange::EXPORT_HELD_SOURCE_TABLES. The rows 22.4 already
wrote stay on file but are hidden from the staff page, the CSV, and “Mark All
Exported” — a row nobody exported must not be marked exported.
Nothing is lost: champion_signups.affinity_codes and affinity_other still hold
what alumni reported, which is what a re-enable would diff. Turning them back on is
emptying that array plus restoring the logger’s affinity branch.
Both the page and the CSV now scope to CrmDataChange.exportable (app_originated
minus held tables) rather than app_originated, in the same one place, so a held
row cannot reach Advancement Services through one door and not the other.
CI: a docs-only skip can no longer launder a red build into staging. Staging
deploys on the CI workflow’s conclusion == 'success', and a docs/CHANGELOG-only
push skips the suite and reports success in ~18s. On August 4 2026 CI #522 failed
on 8f77fb9, the v1.0.83 CHANGELOG commit went green in 18s, and Deploy to
Staging #432 shipped the untested tree. check-changes now reads the preceding
commit’s CI conclusion and drops the skip unless it was success, running the
full suite when the verdict is failure or unknown.
Production was never exposed: deploy-production.yml runs its own full suite on
the tagged commit with deploy: needs: test. Documented in
docs/operations/DEPLOYMENT_PIPELINE.md.
Removed a dead entry from CI’s docs-only allowlist:
app/controllers/champions/roadmap_controller.rb moved to root level in Phase
23.3, so the pattern had matched nothing since. Not re-added under its new
path — root-level app code runs the full suite.
Post-deployment: nothing to run. No migration, no schema change, no rake task.
Follow-up to v1.0.82, covering the one Engagement Stats code path that release missed because it lives in the controller rather than a service.
Matrix quadrant drill-down timed out with a college filter. Clicking “Committed” on the Matrix tab spun and died silently; without the filter it loaded fine on 5x the data (dev, Last FY: 589 rows / 7.52s filtered vs 2,832 rows / 1.00s unfiltered).
EngagementStatsController kept its own copies of
#build_filtered_engagement_scope, #engaged_alumni_count and friends, which
v1.0.82 did not touch because #quadrant_alumni never goes through a service.
The nested Education::AggregateScope subquery landed inside an aggregate that
already carries four correlated subqueries per row (the activity caps), so the
planner re-evaluated the expensive scope per row. BUIDs are now resolved once,
as in EngagementStats::BaseService: 7.49s → 1.35s, identical result set.
A query-count assertion cannot catch this — nesting keeps it one statement —
so test/controllers/quadrant_alumni_efficiency_test.rb asserts structurally
that no statement contains both the UNION ALL and the correlated cap alias.
Noted, not fixed: #quadrant_alumni is outside TimeoutProtection
(only: [:index]), so it runs to Heroku’s 30s H12 and the modal silently
swallows the 503. See BACKLOG §5.0-A.
Post-deployment: nothing to run. No migration, no schema change, no rake task. This release is service/view code, one new shared class, tests and docs.
No measurement definition changed. Every figure on every Engagement Stats tab should read exactly as it did on v1.0.81. All 36 tab × filter combinations were diffed against v1.0.81 on a copy of real data; every value matched. The only intentional differences are tie ordering in the Breakdown description lists and in
top_alumni(both previously undefined, now deterministic).Known caveat: staging QA was not re-run after the final Demographics fix. Verification for that fix is local only — full suite plus a targeted check against dev data. If anything looks wrong on the Engagement Stats tabs, start there.
Engagement Stats: Analytics and Breakdown tabs timed out (“This report is
taking too long to load. Please filter by college or year…”). Reproduced on
local dev data for Last FY: Breakdown 17.1s / 28 queries, Analytics
42.1s / 164 queries, against TimeoutProtection’s 15s budget.
Both services looped over a population and re-ran the expensive
filtered-alumni subquery — the Education::AggregateScope UNION ALL over
70K educations + 52K degrees, with a NOT IN, ~550ms per execution — inside
every iteration. Breakdown ran it once per engagement type (2 queries × 13
roled types); Analytics ran find_in_batches(1000) across all 51K alumni in
the population to score the 2,832 who actually had activity in range.
EngagementStats::BaseService#eligible_buids now resolves that filter once
per service call, narrowing by the indexed activity table first (~2.9K BUIDs
for a fiscal year) before touching the aggregate scope. Breakdown collapsed to
2 grouped aggregates; Analytics is now set-based over the in-range engagement
rows, hydrating Alumni records only for the top 50 scorers.
Measured after (same data, cold cache): Breakdown 1.13s / 6 queries, Analytics 0.94s / 8 queries. Every output value verified identical against independently recomputed figures.
Demographics tab raised ActionView::Template::Error (comparison of Array with
Array failed). Latent since Phase 18.6 (May 2026), not introduced here — the
tab had been timing out before it ever reached the table that crashes. Making it
fast enough to render surfaced it.
DemographicsService used .compact on a grouped count intending to drop the
no-college bucket, but Hash#compact removes entries with nil VALUES, not nil
KEYS — and a grouped count never has nil values, so it was always a no-op. The
surviving nil key then made @engaged_by_college.sort compare [nil, 5]
against ["CB", 3] and raise. Now .except(nil), with the view hardened to
sort_by { |code, _| code.to_s } so a nil key can never 500 the page again.
The nil bucket is real data: AggregateScope emits no college_code for most
Degree-fallback rows (563 of 604 alumni on dev). Those alumni are still dropped
from the college table, matching the original intent — see BACKLOG §5.0-A item 3.
New test/controllers/engagement_stats_tabs_render_test.rb seeds realistic data
(including a college-less credential) and renders all seven tabs, with and
without college/year filters. The previous controller test only ever rendered
Overview, on fixtures with no engagement activity — a green assert_response
:success against an empty page.
Regression introduced by the first pass, caught before release. Collapsing
every tab onto BaseService#eligible_buids silently changed Overview’s and
Analytics’ numbers whenever college AND graduation year were filtered together
(dev data, college=CE + year=2015: engaged 46 → 42, Level 2+ 23 → 22).
Cause: the tabs never shared one definition of who counts. Overview and
Analytics read filtered_alumni.pluck(:buid), which applies college and year
independently — any credential from that college, any credential from that
year, not necessarily the same one. Breakdown, Matrix and Demographics went
through build_filtered_engagement_scope, which additionally requires one
credential satisfying both. Unifying them onto the strict form looked like a
refactor and was actually a measurement change.
Both definitions are now explicit — #population_engaged_buids (loose) and
#eligible_buids (strict) — and each tab keeps the one it has always used.
test/services/engagement_stats/population_semantics_test.rb pins the split.
Deciding which definition is correct is deferred to BACKLOG §5.0-A; it is a
product question, not a code cleanup.
Filtered tabs still timed out on staging after the first pass, which had only
ever been measured with no college/year filter. With a college filter,
filtered_alumni nests up to three unrestricted Education::AggregateScope
subqueries in a single statement, each materializing ~123K credential rows.
Population, college and year are now resolved as separate bounded queries, and
AggregateScope.buid_subquery accepts a buids: restriction so Postgres can
push the qualifier into both UNION ALL branches and use the BUID indexes.
TimeoutProtection cannot interrupt a running query. Staging logs show
Completed 302 Found in 15005ms (ActiveRecord: 14999.2ms) followed by a Heroku
H12 ... service=30000ms status=503 on the same request id. Timeout.timeout
raises via Thread#raise, which cannot preempt a thread blocked inside libpq, so
the 15s guard only fires once the query returns — the user sees Heroku’s 30s 503
(“Something Went Wrong”), not the intended flash message. Logged in
BACKLOG §5.0-A; a database-level statement_timeout is the real mechanism.
IN list across nine queries) and now shares the same
base scope: 5.00s → 1.54s. It was under the timeout locally but is the
default tab and the closest to tripping on Heroku. Outputs verified unchanged.EngagementStats::OverviewService#calculate_flagship_metrics no longer takes a
valid_buids argument it never used.Fiscal year is now defined in exactly one place: FiscalYear
(app/models/fiscal_year.rb, shared layer). The June-1 boundary was previously
written out eight independent times — FiscalYearHelpers,
Education::AggregateScope, EngagementStats::BaseService,
EngagementStats::OverviewService, Degree (×2), EngagementStatsController,
and two inline copies in the Engagement Stats views. They agreed, but nothing
enforced it. Changing FiscalYear::START_MONTH now moves the boundary
everywhere.
The collapse preserved two interval shapes that were not interchangeable:
FiscalYear.range is inclusive of May 31 (Degree.by_fiscal_year,
current_fy_range), while FiscalYear.half_open_range returns an exclusive
June-1 end for Education::AggregateScope, which pairs it with >= ? AND < ?.
Collapsing them into one shape would have shifted every FY-filtered figure by a
day at one edge, silently. test/models/fiscal_year_test.rb pins both, plus
agreement between the Ruby and SQL implementations at all five boundary dates.
No stat value changed — verified against the same recomputation used above.
Post-deployment: nothing to run. No migration, no rake task, no schema change — the whole of Phase 23 is code, routes and docs.
One caveat that only matters if you roll back. This release adds permanent
301redirects from/champions/*to/alumni_network/*, and browsers cache a301indefinitely. Once a staff member has followed one, their browser stops asking the server — so reverting the deploy leaves them on a path that no longer exists, and re-deploying the old code does not fix it. Recovery is a hard reload, per browser. Prefer fixing forward over reverting. On staging, 40 of 46 persistedStaffNotificationrows (87%) pointed at the old paths, which is what the redirects are there for.
A shared layer, so the walls between the five apps have doors (Phase 23.3) — 23.1 and 23.2 built walls on a premise that is false for the core domain objects: that every file belongs to exactly one app. Alumni, Affinity, District, Industry and the rest are shared by all five, and an app is a view onto them rather than an owner of them. Two costs of the omission were sitting in the tree, not argued in the abstract. The champion interest seals lived in the frozen champions/ directory while being rendered by three other apps — so editing a deliberate cross-app design choice was a held-surface write. And industry, a string on three tables across two apps, was validated in the app that merely displays it and not in the app that collects it from a dropdown, so an altered form POST stored a value no report, filter, or community-matching rule would recognise.
Shared artwork now lives in app/views/shared/champion_interest/, each partial carrying a header naming every surface that renders it. Affinity owns both of the category lists that were scattered across two apps, Industry owns the taxonomy, and ChampionSignup validates against it. CLAUDE.md gains the placement rule and — because it looks like a bug otherwise — the note that global helper inclusion is intentional, since it is how a calculation written for one app benefits the rest.
IdentityField: one vocabulary for the three tables that describe the same person (Phase 23.3) — alumni, cp_champions, and champion_signups used different names for the same field, and nothing translated between them. That had already produced a silent defect: Cp::ProfileChange::HIGH_PRIORITY_FIELDS — the list telling Advancement Services which changes matter most — declared pref_name and maiden_name, while the callback feeding it wrote pref_first_name and college_last_name. The constant and the callback live in the same file and disagreed about what a preferred name is called. for_field("pref_name") returned zero rows and always would; nothing raised, and the fields most valuable for CRM reconciliation were the ones dropped.
IdentityField::MAP is a declarative map plus a lookup — explicitly not a translation engine and not a rewrite of either change log. Cp::ProfileChange now translates at query time, so for_field("pref_name") returns 6 rows and for_field("maiden_name") 16 where both were structurally 0. A test asserts every field name either log can write is one the vocabulary knows, which makes the defect class unrepresentable rather than fixed once. The three fields with no counterpart anywhere are recorded as nil rather than papered over, and the conditional legal_first_name mapping is documented rather than encoded, because a flat hash cannot express it and should not pretend to.
This does not solve identity. It gives three tables one vocabulary; deciding which is true when they disagree is Phase 26.
Cross-surface rendering is now enforced (Phase 23.3) — A view may render its own surface’s partials or anything under shared/; reaching into another surface’s directory fails the build. Baseline is empty, because the violations were removed rather than allowlisted. This is the only genuinely enforceable half of the shared-layer rule — a render target is a static string, a helper call is not — and the spec says so plainly instead of adding a test that implies otherwise.
Surface boundaries are now enforced by the test suite, not just documented (Phase 23.1) — This repo is five apps in one codebase, and two of them are on hold. CLAUDE.md described the boundary between /champions/ (frozen Alumni Network Admin) and /champion_signups/ (the live Signup Admin) in a table, in bold, and as a numbered rule — and it was crossed often enough that a hard gate had to be added. The reason is mechanical rather than a matter of care: deciding where a file belongs usually means reasoning over grep output, and grep -rn "champion" returned both directories with nothing indicating which was frozen. test/architecture_test.rb now fails bin/test when a file lands in a held tree, when a new cross-surface Cp:: reference appears, or when a controller declares a layout belonging to a surface it does not live in.
It is a ratchet, not a clean assertion. The tree does not pass a strict version of these rules, so every existing violation is committed as a dated baseline that may shrink and never grow — a test that fails on arrival gets disabled within a week and buys nothing. Capturing that baseline turned up two things planning had missed. EngagementStats::RetentionService and EngagementStats::DiscussionBoardsService are wholesale Cp:: analytics sitting inside engagement_stats/, which CLAUDE.md rules against by name — retention_service’s own header comment says it queries Cp:: tables rather than Alumni. And excluding comments from the reference count cut the apparent coupling inventory from 34 files to 17: half of what a raw grep reports as coupling is documentation about coupling, which is worth knowing before anyone estimates a monolith split off a grep count.
A shared Industry taxonomy (Phase 23.1) — The alum-facing signup flow read its industry list from Cp::Champion::INDUSTRIES, on the frozen belmontalum.com member model. Phase 23.4 renames that class, so the live signup flow would have gone down as a side effect of refactoring a surface nobody is using. The list now lives in the shared, identity-agnostic layer. Cp::Champion keeps its own copy for now — pointing it at the shared constant is a write to a held path — so a test asserts the two lists stay identical rather than leaving it to vigilance.
The Alumni Network Admin is no longer called champions (Phase 23.4) — app/controllers/champions/ → app/controllers/alumni_network/, /champions/* → /alumni_network/*, and Champions:: → AlumniNetwork::. This is the collision the hold gate exists for: /champions/ and /champion_signups/ sat one character apart and administered different products, and grep -rn "champion" returned both with nothing marking one as frozen. Both halves of that pair are now gone — 23.2 renamed the signup side, 23.4 the portal side. 185 files, 1,401 route-helper references, no migration.
The rename was generated from bin/rails routes rather than grepped, because four helper families contain champion and are not this namespace — the cp_champion_* Devise scope, the public cp_champions_landing, alumnichampions_root, and 23.2’s champion_signups alias — and one helper’s own action is named autocomplete_champions. A regex would have mangled all five.
Three items were pulled forward from the cut 23.5 because they had no later home: resources :champions became resources :members (/alumni_network/members, backed by an unrenamed Cp::Champion — the URL is what staff and agents reason over), both layouts named “champion” were renamed, and Cp::Champion::INDUSTRIES was deleted in favour of the shared Industry::ALL. 34 “Champion Program” labels became “Alumni Network.”
Phase 23.5 was cut — the Cp:: → An:: module rename and Cp::Champion → Member model rename, 389 files and 56 tables, will not happen. The phase’s own value-vs-churn ranking had already answered “prevents misroutes? No”: Cp:: is a fossil of “Champion Portal,” but an unambiguous one — nothing else in the repo looks like it, so nothing has ever been misfiled into it. Against that sat the largest file count in the phase, its only migration, its only irreversible step, and the one change that fails silently rather than loudly (a missed polymorphic type string makes associations return nothing instead of erroring). Cp:: is now documented as permanent, not transitional.
The development roadmap moved out of the frozen Alumni Network and onto the lookup (Phase 23.3) — app/controllers/champions/roadmap_controller.rb → app/controllers/roadmap_controller.rb, and /roadmap is now served on alumnilookup rather than belmontalum. It is an HTML rendering of the phase docs describing all five apps: repo development tooling that happened to sit inside a frozen product, which was the only reason CLAUDE.md needed a hand-carved exception letting /phase-wrap write to a held path. That exception is now deleted rather than ported — the point of the move. Non-production only, so no production bookmark is affected; the guard also became host-agnostic, an allowlist of non-production environments rather than a blocklist of one host.
docs/planning/champion-portal/ split, because the name said something false (Phase 23.3) — It had come to hold the development record for the whole repo (25 phases across five apps, the backlog, QA guides) alongside genuinely Alumni Network product docs. phases/, BACKLOG.md, qa/, and development/ moved up to docs/planning/; source/, ai/, features/, and the network’s own overviews moved to docs/planning/alumni-network/. ~230 textual path references and 88 markdown links were rewritten by resolving each link to an absolute repo path before the move and recomputing it after, rather than by hand. Repo-wide broken links went 88 → 85.
The signup ecosystem is split by audience instead of auth state (Phase 23.2) — The alum-facing signup lived under public/, a name describing authentication, which was the wrong axis: the returning-visitor profile hub is session-gated and lives there too. Meanwhile the staff tooling sat at /champion_signups/, one character from /champions/, administering an entirely different product. Both now name their audience: Signup:: / layout "signup" for alumni, SignupAdmin:: / layout "signup_admin" for staff. ChampionSignup and its table are not renamed — “Champion Signup” is the product’s own name for the flow and is correct.
No alum-facing URL changed. /sign-up, /sign-up/:id, /profile/:code, /opportunities/:slug and /privacy all still resolve; only module and helper names moved. The mail-merge outreach to v1 signups uses permanent /profile/:code links that are already in alumni inboxes.
Staff URLs did change, and that turned out to be the risk. StaffNotification#url writes a route path into the database — four notification jobs do it — so every row created before this change points at /champion_signups/.... The rename on its own would have 404’d the entire existing staff notification inbox on click. Eight 301 redirects cover them, ordered specific-first so champion_signups/:id cannot swallow champion_signups/opportunities as id="opportunities", with an end-to-end test that follows a redirect and asserts staff reach the real page. These redirects are permanent, not transitional; the rows are already written. Their route helpers are named legacy_* on purpose — left unnamed, Rails derives helper names from the path and quietly recreates the very names the rename removed, except now they 301 instead of erroring, and nothing would ever flag their use.
The Alumni Network Admin sidebar highlighted nothing, on every page (Phase 23.4) — Caught before shipping, and the more useful half of the sub-phase. The sidebar decides which nav item is active by comparing request.path against 25 hard-coded path literals, not route helpers. Driving the rename off the route table — the right way to do it, since it cannot be fooled by lookalike helpers — rewrote all 1,401 helper calls and left every literal pointing at /champions/....
The result was a fully working admin surface with an invisible defect: identical rendering apart from one CSS class, no test touching it. The same failure was in ActionItemsService’s six navbar-bell links, two of which carry query strings — and dropping ?needs_leader=true is worse than a 404, because staff land on an unfiltered list that looks like a working page while answering a different question. A rename driven off the route table only fixes what the route table knows about.
Persisted staff notification URLs would have 404’d — again (Phase 23.4) — 23.2 found that StaffNotification#url writes a route path into the database; 23.4 found the same hazard on a surface with higher volume, since Cp::NotifyAdminsJob persists one on every member signup. Mitigated with a permanent wildcard 301 rather than 23.2’s enumerated list: two more controllers and two mailers also emit /champions/... URLs, one of them into alumni inboxes, and an enumeration missed all four. The query string is preserved, every redirect is explicitly named legacy_* so Rails cannot silently resurrect the helper names the rename removed, and a test asserts those names no longer resolve.
ChampionSignup accepted any string as an industry (Phase 23.3) — The column had been unvalidated since Phase 19 while Cp::Champion enforced the same list. Existing rows were audited first and none held an off-list value, but three tests did: they stored "Education", "Music", and "Entertainment" — titleized display labels the form’s <select> can never submit, since it submits the underscored key and humanizes only what the alum reads. The divergence the shared layer exists to prevent, found in the test suite rather than the data.
Post-deployment: adds an index on
champion_signups (buid, created_at). Runheroku run rails db:migrateon the target app — there is no release phase in theProcfile, and district search will table-scanchampion_signupsonce per alumni row until it exists.
Everything the v2 signup collects now reaches the people who use it (Phase 25) — The signup flow has been gathering location, interest leaning, student groups and return-visit activity since Phase 19, and none of it was visible in the Lookup Portal. Staff opened a profile and saw a v1-shaped “Alumni Champion Status” card describing a five-step wizard that no longer exists; they searched a district and got CRM addresses only, missing every alum who had told us they moved; they exported a CSV with one champion column and no idea what the alum had actually said. A new Alumni::SignupProfile (same shape as Alumni::EducationProfile) is now the single read layer for all of it, and the profile, the search results, the filters and the export all go through it.
Location resolves at read time and never overwrites the CRM. A signup value is pending truth, not competing truth — ChampionSignupCrmLogger writes it to crm_data_changes, Advancement Services keys it into Affinaquest, and it flows back into alumni on the next sync. So the newest active signup’s ZIP now outranks alumni.zip for both display and district search, while alumni.zip is left exactly as it was. This changes who a district search returns: an alum who told us in March that they moved to Nashville used to keep surfacing in a Memphis search for as long as the CRM round trip took, and never in Nashville — which is the search someone actually ran. Divergence is shown with a “self-reported” badge and the CRM address underneath it; once the two ZIPs converge the badge disappears, because a closed loop flagged as an open one is just noise.
Champion Role is replaced by interest leaning. v2 dropped “pick the role you are” in favor of a four-area spectrum, so the portal crowning someone a “Connection Advisor” described a flow that no longer exists. The profile now shows the full four-bar spectrum (the same partial the public quiz reveal and Signup Admin render) and the badge reads “Leans Bringing People Together”. The seal artwork is reused as-is — the area keys and the old role keys are the same four strings. An alum with no scorable quiz and no explicitly chosen role gets no leaning rather than a fabricated one, which also meant fixing an all-zero spectrum that max_by was silently resolving to whichever key came first. List views (search results, the CSV) read stored interest_scores only; recomputing a v1 quiz through ChampionQuizService fifty times a page — or 49,000 times an export — is not a trade worth making for a decorative chip.
Student groups are shown as two sources, because they will never be one. ChampionSignupCrmLogger only ever read alumni_affinities to diff against; nothing writes signup-reported groups into it, and nothing should — they reach the CRM through the export and come back on a sync. Affinity search now matches either source, and every chip carries its provenance in its fill — tinted for BruinQuest-confirmed, plain white for self-reported-and-not-keyed-in-yet — with the free-text “other” group shown on the profile. Showing them identically would present a self-report as CRM fact.
The CSV gains a signup_* block — city, state, ZIP, district, location_source, top_interest_area plus all four area percentages, and self-reported affinities. The existing district and affinities columns keep their current CRM-only meaning, so sheets built against this export do not silently change what they are reporting.
ChampionSignup#calculate_status returns 5 (“Completed”) the moment a ZIP is present, so every v2 signup is complete on creation and “Show Signups In Progress” can only ever surface a v1 straggler. The dropdown is now All / Completed the Champion form / Has NOT completed the form, and the manual prospect_status flag — a staff judgment about someone who hasn’t filled the form out, not a competing degree of completion — moved to its own “Manually flagged prospect” checkbox. Retired param values map forward through Alumni::ALUMNI_FILTER_ALIASES so bookmarked URLs keep working and label themselves with what they now do; champions_and_prospects resolves to “all”, since the distinction it drew no longer exists. “Has Alumni Network Account” is untouched and stays separate — that filter is the belmontalum.com portal and must not bleed into signup completion./settings/affinaquest to /settings/bruinquest (the old path 301s, so saved links and anything Advancement Services bookmarked keep working). The Ruby side — Settings::AffinaquestController, AffinaquestImportBatch, the affinaquest_import_* tables, the affinaquest_updated_at / affinaquest_synced_at columns, and the affinaquest_import change-source value — deliberately keeps the old spelling: renaming those is a migration with real risk and zero visible benefit, and a half-renamed identifier is worse than a consistent one. A controller test asserts the import page never renders the string.filter_by_alumni_status still understands no_form so legacy bookmarks resolve, but who we haven’t reached is a question for a later phase, and a checkbox that returns every alum Belmont has is not one staff should trip over on the way to something else. A view test asserts the option is absent.alumni#show uses the role icon, never the full seal — a seal is a circular badge with the role name baked into the artwork, so nesting one in a bordered card double-frames it and repeats a label the markup already renders. The public landing page and the spectrum bars had always used the icon; the profile now matches.alumni#show are one list — BruinQuest-confirmed groups and signup-reported ones were rendered as two separate sections with different visual treatments, which made a self-report look like a lesser kind of fact rather than a differently-sourced one. Now a single list, equal weight, each entry carrying 2003 - 2004 • Webmaster where the detail exists and a Source: BruinQuest / Source: Champion Signup line underneath. A BruinQuest row shows more because BruinQuest holds more, not because it outranks the alum. The free-text “other” group is now an entry in that list instead of a footnote, and only BruinQuest rows show edit/delete — a signup-reported group has no record behind it to edit. Search-result chips, which have no room for a source line, carry the distinction in the fill instead: same solid border on every chip, tinted background for BruinQuest, white for self-reported. Dashed borders did that job first and read as broken rather than provisional — especially six across in one table cell.<option>s carried Affinity#id while filter_by_affinities matches on affinity_code, so submitting without first changing the category filtered on a number against a column holding codes like BAMB and returned nothing. It went unnoticed because the affinity-selector Stimulus controller replaces the whole option list with real codes as soon as a category is picked, which is the normal path through the form.bg-#{color}/10, text-<%= color %>), which Tailwind’s JIT cannot see and therefore never compiled, so the card rendered with no color at all. Rebuilt on a literal class map, alongside the same fix in the profile header badge.Alumni#reload now clears the memoized signup profile — reload drops association caches but not plain instance variables, so current_champion_signup and signup_profile would survive it and keep answering with pre-reload data, in exactly the situation where a caller reloads because something changed.champion_signups:backfill_crm_changes rake task — the Phase 22.3-22.5 CRM feedback loop (ChampionSignupCrmLogger) only ever ran live, from a controller after_action, so signups completed or updated before that shipped never produced CrmDataChange rows. The logger diffs a signup’s current columns against the linked Alumni’s current columns rather than a point-in-time snapshot, so no historical data is needed to backfill — this task simply calls the same logger once per already-linked signup. Idempotent (already_logged? guards re-runs); ships with a dry_run mode that runs the real logic inside a transaction and rolls it back so nothing is persisted during preview.
bin/rails champion_signups:backfill_crm_changes[dry_run]
bin/rails champion_signups:backfill_crm_changes
OpportunityMarkdownTest traced to a memoized Redcarpet renderer — OpportunityMarkdown#renderer cached a single Redcarpet::Markdown instance for the life of the process (@renderer ||= ...). Redcarpet wraps a stateful C parser: if a call into a shared instance is ever interrupted mid-render — every test is wrapped in Timeout.timeout, which fires via async Thread#raise — the parser’s internal buffers are left dirty and every later call on that instance is corrupted for the rest of the process. That surfaced in CI as two unrelated-looking failures on two different tests in the same file: markdown.c:2927: sd_markdown_render: Assertion followed by TypeError: wrong argument type String (expected Redcarpet/rndr). It read like a CI infra fluke — not locally reproducible, two different-looking crashes — but was a real defect: local runs are fast enough that the timeout rarely fires mid-render, so the corruption path almost never triggers there. Fixed by building a fresh Redcarpet::Markdown instance per call instead of memoizing one; a fresh instance has no state left over from a previous, possibly-interrupted call to corrupt. Cp::WelcomeContentGenerator, the only other Redcarpet caller in the codebase, already built a fresh instance per call and was unaffected.Post-deployment: this release adds
crm_data_changes.champion_signup_id. Runheroku run rails db:migrateon the target app — there is no release phase in theProcfile, so an unmigrated deploy will 500 on the CRM changes page.
Staff notifications are finally readable — a real inbox at /notifications (Phase 22.1) — StaffNotification has been a complete, correct model since Phase 1: six jobs write to it, it has read_at, clicked_at, mark_read!, an unread scope and a seven-entry type list. It had no controller, no route, and no view. Every row was written to the database and surfaced only as a transient web push — miss the push and the notification was gone forever. There is now a full-page inbox: filter by surface, by type, and by read state, paginated, with the receive → clear → gone flow the model was always built for. It is per-user, and enforced at the lookup, not the view — every action scopes to current_user.staff_notifications, so a colleague clearing theirs can’t hide an item from you and can’t be probed for one either. Clicking a notification is the read: it stamps read_at and clicked_at (a column that existed and was never once written) and forwards you to whatever the notification points at, while “Mark all read” deliberately sets only read_at — that distinction is what makes clicked_at mean “I went and looked” rather than “I dismissed a batch”. Surface grouping is data-driven, not a view conditional: a new StaffNotification::SURFACES map is the single place a notification type declares whether it belongs to Champion Signup or Alumni Network, and a model test fails the build if a type is ever added without one. Because the list paginates, ordering is surface-major with recency inside — sorting by recency alone would repeat both headings on every page. The inbox lives at the unnamespaced /notifications on the shared application layout rather than under /champion_signups/ as the phase spec first called for: the feed spans both surfaces and the bell that opens it renders in layouts/_navbar, which all five Lookup Portal layouts include, so filing it under the signup namespace would have put Alumni Network notifications at a signup URL. (/notifications is free on the Lookup Portal — the Cp:: one of the same name sits inside the belmontalum.com host constraint and never matches on *.alumnilookup.com.)
What alumni tell us through the signup now reaches Advancement Services (Phase 22.3–22.5) — CrmDataChange has been a mature pipeline since Phase 1 — a pending → exported → verified lifecycle, export batches, field protection against Affinaquest overwrites, and a working staff export at /settings/affinaquest/crm_changes. Not one signup had ever written to it. Its only alum-facing caller was the belmontalum.com portal, so an alum could update their address, their employer and their student groups through alumnichampions.com and it would land in champion_signups and die there. A new ChampionSignupCrmLogger closes the loop: it runs on every public save point and on staff linking, resolves the alumni record by BUID, and logs only what’s genuinely new under a champion_signup source kept deliberately distinct from champion_portal — Advancement Services needs to know which product the alum used. Contact fields diff against the alumni record, with the mapping spelled out rather than assumed (zip_code → the zip column) and an email checked against all five of alumni’s email columns, so an address already on file as email_business isn’t reported as a change. street is the one field with nowhere to diff against — alumni has no street column — so it logs as a plain “they report this” with a blank old value rather than being dropped. Employment matches against every one of an alum’s employment rows, not just the most recent. Alumni genuinely hold more than one current position (that’s why Phase 24 made the table one-to-many), so checking only the latest would flag someone’s second job as a brand-new employer every single time they saved. A new employer diffs against the most recent position; an alum with nothing on file logs a blank old value. A match doesn’t end it — a promotion inside the same company is reported too. The first real test of the feature was someone changing their job title at an employer we already had, and the original rule (“any employer match suppresses”) dropped it silently; the title now diffs against the position that matched, so an alum promoted at their older employer isn’t compared against their newer job’s title. Identical title, or none given, still logs nothing. Industry rides along as a note, since there’s no column to diff it against. Student groups log additions only — a group missing from a form is silence, not someone announcing they left an organization — and free-text “other” groups log under their own field with a note that they need staff review, never as a code that would fail against the affinities table. Nothing an unlinked signup reports is lost. A change with no constituent to attach to is unusable, so unlinked signups log nothing and instead surface in Signup Admin as “needs identity match” — narrower than the existing “not linked” filter, meaning unlinked and actually holding data the export is waiting on — with a dashboard callout, a list filter, row badges, and a note on the detail page saying exactly which fields are being held. Linking an alumni record runs the logger retroactively and tells you how many changes it just queued. Two things were fixed on the way through that would have made the whole feature silently useless: the app_originated scope that both the staff page and the CSV are built on was a hardcoded source list (a new source added to the model but not to that list writes perfect rows and ships none of them — it’s now derived from the sources, with a test), and the protecting-source list was inlined in two places that could drift, so a signup value would have blocked an import overwrite on one code path and not the other. The export itself was extracted out of the controller into Csv::CrmDataChangeExporter — it had been building CSV inline with an Alumni.find_by per row — and gained two columns: Champion Signup ID for traceability, and Source Table, which became load-bearing the moment employer_name and affinity_code joined alumni, since several rows can now share a BUID and a field name and only the table says what to update.
city meaning “what the alum typed”. This also mattered for the CRM loop above: it would otherwise have reported the stale city to Advancement Services as though the alum had asserted it. Cp::Champion and OpportunityResponse derive location the same way and already overwrote unconditionally, so neither was affected — ChampionSignup was the outlier precisely because of the hand-written exception.ActionItemsService only: live aggregate counts, explicitly documented as “not persisted”, of which six of eight items linked into the held /champions/ surface. The one active-surface signup item was a 7-day rolling count of new signups — the “bucket of 2 new updates” that clicking through changed nothing about, and that only disappeared when the signups aged out. Nothing at all represented opportunity responses, returning-visitor updates, or consent changes. The dropdown now has a New section (your unread notifications, clearable — per-item click marks read, “Mark all read” clears the lot) above a visually distinct Pending work section (the standing queues, relabeled to read as counts that resolve themselves as the work gets done). The badge counts unread notifications only. It previously showed priority_count, a sum of support requests, champion verifications and moderation queues — every one of them on the held surface, none of them clearable from the bell. The number staff see is now the number they can actually act on; pending work carries its own muted count beside its heading. priority_count and its discussions_needing_moderation_count helper were removed outright, since the badge was their only caller. Alumni Network action items collapse under a single de-emphasized heading while that surface is on hold, driven by a new surface attribute on ActionItem with a test asserting that the held-surface grouping and the /champions/ paths can never drift apart. The footer link, which pointed at champions_root_path, now goes to the notification inbox — the label rename is Phase 23’s job, not this one’s.nowrap. In practice the icon read as oversized next to everything else in the row, and a long combined string couldn’t wrap — nowrap forces the column’s minimum width to its full one-line width — so it dragged the Name column wider and squeezed Degree and Affinities into a sliver on the right. Redesigned LinkedIn-style: job title and employer are now two separate lines directly under the name (icon dropped), each allowed to wrap instead of forcing column width, with district and BUID/BQID swapped below them. Two more row-level fixes landed alongside it: the avatar now top-aligns with the name block instead of centering against it, and the engagement scorebar — previously a sibling of the avatar+name row, so its left edge lined up with the avatar — now lives inside the name column, lining up with the name/title text instead. Phase 24 is fully wrapped as of this change.champion_signup_id alone conflated two very different confidence levels, so a new signup_link_method column records how the link was made: verified (they arrived with a profile token or an active session and submitted from their own hub — this is them) or email_match (the address they typed belongs to a signup on file — probably them, and nobody confirmed it; shared inboxes, couples and typos all land here). A verified submitter now gets a personalized thank-you (“Thanks, Chip”) and a Back to your profile button, and is no longer asked to “make it official” or offered “already signed up with us?” — they plainly are. Everyone else, including an email match, still gets the identical anonymous page, since treating a guess as proof would make the form a backdoor login and reintroduce the enumeration leak. The personalization requires live proof on the request, not the stored flag, so a stale session can’t unlock it. In Signup Admin the “Are they on file?” panel became three states with matching list badges and filter options — Confirmed — same person, Likely — matched on email (“don’t treat it as confirmed”), and New to us — and the CSV gained a signup_match column so a list handed to whoever does outreach doesn’t present a guess as a confirmed identity.max-w-2xl column, which read as an undifferentiated pile of white boxes and buried the highest-value action fifth. It’s now max-w-5xl with the things to do (opportunities, profile sections) in a main column and reference material (contact details, the alumni team) in a sidebar, collapsing to one column on mobile. The lead block adapts: an unfinished profile leads with “About you” and the ask to complete it; a finished one leads with “Ways to show up” and collapses the completed sections to a quiet “All set — thank you!” strip with their explanatory blurbs dropped, so a finished profile stops looking as noisy as an empty one. A missing ZIP still outranks both and is hoisted full-width under the header. The standalone “why more info helps” surface was folded into the section heading and progress bar it was explaining./champions/opportunities while the leads it produces belong to the signup ecosystem. Two admin shells for one feature is wrong, so the editor relocated to /champion_signups/opportunities and the responses queue landed beside it under a new Opportunities sidebar section. ensure_portal_admin! is now declared on the controller rather than inherited — editing a live public form is a publishing action, so it stays portal-admin-only while the responses queue is staff-level like every other lead list in that shell. The old /champions/opportunities path and its Champions sidebar entry were removed outright with no redirect: nothing external pointed there and nothing had been authored in production. A namespace/shell move only — models, migrations, the row editor and the public field partials are untouched.send_data response is an attachment with no document to render, so Turbo never completes the visit. The progress bar spins indefinitely, no file arrives, and there is no error, no console warning, and no failing test — the controller test passes because the endpoint itself was always fine. The bug lives entirely in the link markup, which is exactly why it had shipped eleven times. Every export link now carries data: { turbo: false }: opportunity responses, Affinaquest conflicts and CRM changes, orphaned alumni, the education-import gap CSV, data health unmapped schools, both statistics downloads (degrees and lifestage), batch search, legacy verification, engagement-stats activity pairs, and champion email logs. This class of bug is now test-enforced rather than documented and forgotten: test/views/csv_export_links_test.rb parses every controller for actions that reach send_data/send_file — following one level of indirection, so an index that delegates to a private send_csv_export is still caught — resolves them to route helpers, and fails if any view links to one without an opt-out. A new export shipped with a naive link fails that test before it reaches staging; nobody has to remember the rule. Two things the scanner had to get right: comments are stripped before matching call sites (a stray # persisted to disk for download/resolution otherwise parses as a call to #download and taints every caller), and a helper serving both HTML and a download is only flagged when the link actually requests the file, so a “Back to Logs” link isn’t a false positive.parameterize(separator: "_") does not strip hyphens already in the string; its regex whitelists - alongside alphanumerics. So an everyday title like “Send-Off Party” generated send-off_party, which then failed the Opportunity model’s own slug format validation, and the save died with “Slug may only contain lowercase letters, numbers, and underscores” on a title staff had every reason to type. Opportunity#slugify now folds hyphens into underscores, collapses runs, and trims the edges, so any title produces a slug that validates. Regression tests cover a hyphenated title and one with punctuation at the edges. No other caller in the codebase uses that parameterize form.OpportunityMarkdown) with escape_html and safe_links_only: raw HTML is shown as text rather than parsed, and javascript:/data: URLs never become links. The existing Cp::WelcomeContentGenerator.markdown_to_html was not reused despite the spec calling for it — it sets neither flag and ends in .html_safe, which is safe for its own developer-authored YAML input but would have been stored XSS here. That method is unchanged and still serves welcome packs.employments table now holds it, with the import wired end to end at Settings → Data Imports → Employment (CRM) on the same background scan → preview → commit flow as the Phase 18.3 education imports (upload, poll a live progress bar, review, commit — no 30-second request to time out). It stores current employment only, one-to-many. The source feed emits an alum’s current positions and nothing else, so an employment history table would be populated with fiction; alumni genuinely holding two jobs get two rows. The import is a per-BUID replace, and the preview says so before you commit. For every BUID the file names usably, the file’s rows become that alum’s complete current set and existing rows the file omits are deleted — which is the only way stale employment gets removed rather than accumulating. Alumni absent from the file are untouched, so a partial export is safe where a global truncate-and-sync would wipe everyone it omitted. Because that is destructive by design, to_delete is a first-class count alongside create/update/skip: the batch page shows a red banner naming how many records will go and a table listing each one, and the operator sees the number before clicking Commit rather than discovering it after. A BUID whose only rows are unresolvable (bad alum, no upsert key) is deliberately not treated as a replace instruction — their real rows survive rather than being destroyed on the strength of a broken row. Upserts key on the Salesforce Employment: ID, so re-uploading an identical file reports 0 created, 0 updated, 0 deleted. The export pads with thousands of ,,,,,,,, rows; those are dropped at parse time and never reach the counts. Only three things stop a row being written — no BUID, a BUID matching no alum, or a missing Salesforce id to key on — and each lands in a downloadable gap CSV carrying both BUID and BQID for cross-reference. A missing employer name no longer blocks the row, and isn’t treated as a gap at all. It’s the largest blank bucket in the real export; skipping those rows threw away the job title, start date and record id that came with them, so they now import with the employer blank (rendered as a labeled “no employer name” rather than an empty cell). Unlike an unresolved BUID or a bad date, there’s nothing upstream to chase on a missing employer — the CRM simply doesn’t have one — so it stays out of the gap CSV entirely and only shows as a one-line warning in the preview. A bad start date or an unrecognized Started value is different: those do land in the gap report, imported blank, rather than being silently nil’d. Dates run through the shared Csv::DateParser, so 4/1/84 is 1984 and 7/27/26 is 2026. Employer names are normalized (unaccent, downcase, punctuation stripped) into a trigram-indexed column, with legal suffixes deliberately kept — stripping them would collide genuinely distinct entities. Profile display and employer search shipped separately as Phase 24.4–24.5, below.universal music finds Universal Music Group (partial match), and a typo like vandrbilt falls through to a trigram match ordered by how close it is, showing the same “showing closest spelling matches” notice the name search uses. Your term is normalized exactly the way the importer normalized the stored value, so Prince Properties, LLC typed verbatim matches. soundex is deliberately not in the mix — it’s tuned for single surnames and turns multi-word company names into false matches. Matching runs through a subquery rather than a join, which is the difference between an alum with two matching positions appearing once and appearing twice — in the results list and in the CSV. Results rows gained an employer + title line under the district, showing the most recent position only so row height survives 50 results a page; the full set lives on the profile, where a new Employment card after Degrees lists every current position most-recent-first with its start date rendered at the precision the CRM actually has it (Since Aug 2013 when the date is known, Since on or before Mar 2026 when it isn’t, nothing at all when there’s no date). Rows imported without an employer — a large bucket, kept for the title and start date they carry — render as “Employer not on file” rather than an empty heading, and can’t match an employer search, which is expected rather than a bug. The card carries a one-line note that this data comes from BruinQuest and changes only by import, since editing it in the app would be silently reverted by the next run (that’s Phase 22 territory). The alumni CSV export gained employer and job_title, most recent only./opportunities/:slug: a shareable, anonymous-capable page rendering the questions staff authored, with the contact block that turns an answer into a lead. Submitting records an OpportunityResponse, alerts staff in-app and via web push, and lands on a per-opportunity thank-you page carrying the staff-written copy and “what happens next”. An inactive or unknown slug 404s — there’s no public index, and no “this is closed” page that would confirm a link was once real. Spam protection is the signup flow’s honeypot (silent fake success: the bot gets the same redirect, nothing is written, nobody is notified) plus a new per-IP rack-attack throttle of 5 submissions per 30 minutes; legitimate repeats are days apart, so it never bites a real person. Privacy consent follows the 19.11 treatment — a plain disclosure + link, no checkbox, with the version shown recorded on every response. The thank-you page reads identically for everyone, whether or not the submitted email matches a signup we already have: no “welcome back”, no name echo, and never a link into the profile hub. A public form that revealed or granted on-screen access to someone else’s record on an email guess would be a backdoor login, and identical copy also closes the email-enumeration leak. The only route to a returning signup’s record stays the link emailed to the address on file, offered to everyone by the same CTA. A test asserts the two pages are byte-identical once per-request tokens are normalized out.source: opportunity_hub). The section disappears entirely when nothing is live; an empty “nothing right now” block would be worse than silence. Known-signup views and submits record opportunity_viewed / opportunity_submitted ChampionSignupEvents; anonymous traffic records nothing, since it has no signup to hang an event on and the response row is its record.ChampionSignup when there is one. Opening a response marks it reviewed (idempotent, so a reload doesn’t move the timestamp) and there’s a manual Mark unreviewed toggle; the sidebar badge counts unreviewed, not total, because a badge that only ever grows stops being a signal. This is read/unread only — deliberately not the deferred applied→confirmed→completed workflow. Every response fans out one StaffNotification (in-app + web push, no email) to admins and portal admins via NotifyOpportunityResponseJob. Csv::OpportunityResponseExporter exports one opportunity at a time with dynamic columns — one per question in the opportunity’s own field order, retired ones included and marked — plus contact, city/state/district, consent, champion_signup_id and, when the linked signup has a BUID, both buid and contact_id. A union CSV across all opportunities is deliberately not offered: answer keys differ per form, so it would be sparse and grow a column every time staff author a new one./champions/opportunities, portal_admin only): title, shareable link, three markdown copy blocks, and a repeatable question editor covering the five field types. A Preview renders the real public form off the real partials — disabled, unsubmittable, and it creates nothing — so what staff approve is literally what an alum will see. Duplicate deep-copies an opportunity (inactive, fresh link, regenerated question keys, no responses) since most new opportunities are variations of an existing one. Going live is a toggle, not a deploy. The editor makes the destructive moves impossible rather than merely discouraged: a question’s key is shown as permanent metadata, “Retire” replaces delete (retired questions leave the form but keep their answers in the staff view and export), the link renders read-only and is stripped from the submitted params once it’s been shared, an amber banner explains what’s still editable once people have answered, and Delete disappears entirely once a single response exists.opportunities holds staff-authored definitions — title, URL slug, an active toggle, three markdown copy blocks, and an ordered fields jsonb array — so a new opportunity is authored in the admin rather than shipped in a deploy (spec §2c). opportunity_responses is the submission: contact block, city/state/district derived from the ZIP via ZipCode.lookup, consent stamps mirroring 19.11, and answers jsonb keyed by field. Three integrity rules live in the model, not the UI, because they’re what make editing a live form non-destructive: a field’s key is minted once from its label and frozen forever (answers are keyed by it), fields are soft-deleted rather than removed (an inactive question stops rendering but its answers stay readable), and the slug locks once an opportunity has been activated or has responses — keyed off activated_at so the documented deactivate → fix → reactivate rollback can’t quietly break links already shared. A submitted email matching an active signup auto-links champion_signup_id server-side only; nothing about the match may surface on the page, since a public form that revealed or granted access to someone else’s record on an email guess would be a backdoor login. Responses stay identity-agnostic (nullable FK only, own table) so a future portal surface is an additive column, not a rebuild.district.name. Fixed everywhere location is shown: the public profile hub (sign-up/:id) now confirms “We have you in Cornwall On Hudson, NY · 12520” — the city resolved from the ZIP via a new ChampionSignup#primary_location / #resolved_city (never the metro district on this user-facing card), plus the entered ZIP echoed back so people can verify it. The staff signup detail page and the belmontalum.com portal (My District sidebar, own profile, directory) got the same treatment: City, ST · ZIP as the bold primary with the district demoted to a faded “Part of the {district} district” reach line (Cp::LocationHelper, Cp::Champion merged city/state). The directory stays privacy-aware — it shows a champion’s city only when their address privacy is Public, never leaks the ZIP to other alumni, and keeps district-only records district-only. Tests: new helper + model coverage and controller regressions reproducing the ZIP-only “Kiryas Joel” case; full suite 4,742 runs / 0 failures / 0 errors.whitespace-nowrap on the degree/lean cells stops the earlier wrapping./champion_signups/all was a data dump that “segmented for the sake of segmenting”; it’s now a tool for answering a specific ask (find people in a metro for an event, in a group for a reunion, in an area for a panel). Every filter passes a “find people who __ so that I can __” test. New capabilities: a Sort control (Most recently active — the default — / Newest / Name / Rarest group first); Leans toward [area] as the person’s dominant interest, replacing the deprecated Role column with a sortable one-word chip; and rare/popular group targeting — each Belmont group’s total size is precomputed, rare groups (≤5) are annotated inline (e.g. “Chess Club · 2”), and a Group size band filter surfaces reunion-able niches (≤5, a member could host it) or movement-sized cohorts (≥15). Columns collapsed to Person / Last activity / Location / Interests-&-Groups / View (Date, Status and Role folded into Person and Last activity), and the Interests cell now wraps — together the real fix for the residual horizontal scroll. The person’s name links straight to their signup, and the Leans chip is color-coded to the role it names (connection→fountainblue, digital→skyblue, community→belmontblue, giving→belmontred). The lean is derived from stored interest_scores when present and otherwise computed from the quiz answers, so v1 signups — who answered the same quiz but never had scores backfilled — get a lean too (ChampionSignup#top_interest_area).created_at, so event-less/legacy rows still appear) with profile-update events. Answers “who’s new AND who re-engaged, and exactly what they touched” at a glance.college_code/major_code — v2 self-reported columns that are empty for nearly everyone — so a linked alum with real degrees showed “College Code: Not provided” even though their alumni profile listed a BS in Accounting. The Education section now reads the linked alumni’s Alumni::EducationProfile#all_entries (the same verified read path alumni#show uses), rendering every awarded degree; the self-reported codes remain only as a decoded fallback for unlinked v2 records. Regression test guards a linked signup rendering its real degree.alum.educations.first, so multi-degree alumni saw only one of their degrees; one 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”). New ChampionEducationService returns every awarded degree via Alumni::EducationProfile#all_entries (Education-first with legacy Degree fallback), oldest first, deduped on degree/program/college. One degree renders as a sentence (“We have you down as a BS in Accounting from the Jack C. Massey College of Business (2020)”); two or more render as a bulleted list closing with “If something’s off — or there’s one you’d rather we lead with — just let us know.” 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”). Still shown only for BUID-linked signups.champion_signups — last_return_visit_at (opened their link) and last_enriched_at (saved an optional section >1h after signing up, i.e. told us something new) — stamped by a ChampionSignupEvent after_create callback and backfilled in the migration from events already on file, so activity predating this release is captured. Scopes returned/enriched/returned_or_enriched/never_returned. Surfaced three ways: “Came back”/”Added info” chips + a Return Activity filter on /champion_signups/all (counts follow the v1/v2 tab); a header summary and plain-language Activity timeline on the signup detail page; and on Signup Insights, the v1 came-back vs added-info split, which sections returners filled in, a v2 comparison, and the 20 most recent returners by name. NotifyChampionSignupReturnJob alerts admins on the first enrichment per signup (once per person, not per section) via in-app + web push — deliberately no email, so re-outreach campaigns don’t flood the admin inbox.sms_opt_in to any texting tool, and re-pulling the full list before every send isn’t practical, so every grant and withdrawal becomes a human task. A ChampionSignup after_commit records a consent_changed ChampionSignupEvent and enqueues NotifyChampionSignupConsentChangeJob, which creates a StaffNotification (in-app + web push) stating the action outright — “Add to the text-message list: Jane Doe (615-…)” / “REMOVE from…”. Fires on every change (unlike the return alert’s once-per-signup) because each one is a separate list edit that has to actually happen, and on create too, since a new signup who opts in still needs adding. The event row is also the only permanent record of an opt-out: the model NULLs sms_opt_in_at on withdrawal, so the column can’t say when someone left. Visible in the signup’s Activity timeline as “Opted IN/OUT of text messages”.privacy_policy_accepted_at/_version on a new signup to record which version was shown (never on an edit, so v1 legacy rows aren’t given a consent they never saw). 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 hub now links straight to that page instead of offering a checkbox that pointed at nothing. Columns marketing_opt_in/marketing_opt_in_at removed; the newsletter is promoted through other channels (Champion emails, etc.) rather than cluttering the signup hub. Text messages: the one real gate (TCPA prior express consent) stays, and now rides contextually on the contact-info form’s phone field — the SMS opt-in reveals only once a number is entered, so the separate reach_you consent step was removed altogether.Csv::ChampionSignupExporter gained privacy_policy_accepted_at, privacy_policy_version and sms_opt_in(_at). A list handed to whoever sends a text previously carried no record of who agreed to what. Blank/No means no recorded consent — which for SMS means do not text./champion_signups/all forced a horizontal scrollbar around 1270–1570px because all seven columns were fixed-width with generous padding. Columns now drop progressively via container queries keyed to the table card’s own width — not the viewport, which mis-fired because the fixed 256px sidebar makes the content area much narrower than the window (a viewport xl: reveal showed columns the ~1000px content box couldn’t fit). Role + Interests hide below @6xl (1152px of card width), Location below @3xl (768px); Date, Status, Alumni and Actions always stay, cell padding tightened px-6→px-4, and overflow-x-auto remains as a last-resort safety net. The mobile card view (<sm) is unchanged.px-6 → step container px-4 → white card p-6), burning ~64px of side gutter on a 375px phone; only the landing escaped via its -mx-6 full-bleed sections. Tightened the layout to px-4 sm:px-6 and dropped the redundant middle px-4 on the step shell and profile hub, so it reads as one clean gutter around a single card (~+48px usable width on mobile); the landing’s full-bleed breakouts became -mx-4 sm:-mx-6 so they track the new gutter without overflowing. Two related fixes: the returning-user resume banner no longer horizontal-scrolls (its two whitespace-nowrap buttons stack on mobile), and the “I had a different last name” toggle is left-aligned with single-line copy in both states so opening it no longer shifts the fields below.load_signup was if session… elsif token…, so a session[:champion_signup_id] pointing at a soft-deleted or vanished record blocked the token branch entirely and left @signup nil. Two user-visible symptoms: people arriving on an emailed link who clicked an optional section were bounced to a blank “Let’s start with you” form (the STEPS_REQUIRING_SIGNUP guard also dropped the token on redirect), and the hub’s “Edit” link 500’d with undefined method 'maiden_name' for nil because editing_who_you_are? ran its own token lookup and disagreed with load_signup — returning true while @signup was nil, which skipped the ChampionSignup.new fallback. The explicit token now resolves first with the session as a fallback, dead session ids are deleted, editing_who_you_are? reads the single resolution (@token_signup), and an unresolvable token on a signup-requiring step goes to the expired-link prompt instead of a blank form. Token-first also closes a data-integrity hazard: under session-first, opening someone else’s emailed link while holding your own session rendered their hub but loaded your record into the edit form.SignupHoneypot::MIN_SECONDS = 3 is calibrated for the 6-field signup form, but the profile-link form is one field and a button; a returning user pasting their address or accepting autofill submits in ~2s and was classified as a bot. Because the guard responds with a silent fake success, the failure was invisible — same “check your email” screen, no email ever sent, only a [honeypot] blocked log line as evidence. spam? now takes min_seconds:; request_link passes the new SHORT_FORM_MIN_SECONDS (1s, still stopping a bot that POSTs straight after its GET) while the signup form keeps 3s.NotifyChampionSignupAdminsJob mirrors Cp::NotifyAdminsJob: on first completion of step 1 it sends the admin email and creates a StaffNotification (new champion_signup type) per admin/can_portal_admin user, whose after_create_commit fans out to web push. The controller now enqueues this job instead of calling admin_notification directly. To match the push audience, the admin email recipients moved from the static CHAMPION_SIGNUP_ADMIN_EMAILS env list to the same admin/portal-admin User set used by belmontalum.com (the now-unused config/initializers/champion_signup.rb + CHAMPION_SIGNUP_ADMIN_EMAILS var were removed). Tests: 5 job tests, updated mailer recipient/no-op tests, updated controller enqueue test; full suite 4,668 runs / 0 failures / 0 errors.belmont.edu/privacy/, which no longer resolves; it now points to belmont.edu/notices/privacy-policies/ and displays the full URL.config/faq.yml + Cp::HelpController) now filters FAQ questions/categories by a feature:/features: tag: the “Champion Roles” category, the news question, the event questions (incl. “career events”), and the whole “News & Events” category when both are off, all drop out. Settings → Notifications hides the “New Events” preference toggle when events is off. “My Submissions” hides the Share-News / Submit-Event buttons (and adapts its empty-state copy) per flag, and the “My Submissions” nav link hides when both news and events are off. cp_feature_enabled? moved from Cp::BaseController up to ApplicationController so it’s available to the ApplicationController-based controllers that also render the champions layout (Help, Policies). Legal pages (Terms, Privacy, Community Guidelines, Cookies) were reviewed and intentionally left unchanged — they are versioned, consent-tracked documents and their news/event references are forward-looking. Tests: 5 new (FAQ category/question filtering, settings toggle, submissions buttons); full suite 4,662 runs / 0 failures / 0 errors.champion_role gates the roles microsite, role detail, and Champion Info pages (Cp::RolesController/RoleIdeaClicksController/ChampionInfoController), the role-quiz wizard actions (Cp::ProfileWizardController#quiz/save_quiz_answer/quiz_results/select_quiz_role), and home#refresh_role_ideas — all redirect to the dashboard when off. Cp::DashboardVisibility now also gates role_ideas and champion_nudge on champion_role and your_impact on its own flag, so the role-ideas card, the “Find Your Role” nudge, and the Your Impact sidebar disappear regardless of journey stage. Role badges are hidden portal-wide from a single choke point (champion_role_badge helper returns nil), covering directory cards, discussions, boards, the dashboard hero, and the profile page; the dashboard role card, hero role text, and the profile Champion Role section are gated directly. Your Impact is independent of Champion Role, as specified. Note: the wizard has no discrete “role step” to skip (a stale doc comment implied one) — the quiz is a set of dedicated actions, gated directly. Tests: 9 new (role/impact controller redirects, badge+section hidden on profile, all-five-off dashboard smoke, DashboardVisibility gating + Your-Impact independence); full suite 4,657 runs / 0 failures / 0 errors. Phase 20 complete.Cp::NewsController/NewsSubmissionsController → news, Cp::EventsController/EventSubmissionsController → events, Cp::PhotoAlbumsController → photos) redirect to the dashboard via a require_feature! before_action (the public events#show page too). Cp::DashboardVisibility gains a SECTION_FEATURE_FLAGS map so show?(:events) returns false when the flag is off — one choke point covering the desktop sidebar, the mobile “events first” block, and event loading. The dashboard “What’s New” bar zeroes flagged-off content types and the activity feed drops news/photo items (discussions stay). The community page wraps its News, Photo Albums, and Upcoming Events sections in cp_feature_enabled?, reflowing cleanly with no empty gaps. No nav/footer changes needed (these features have no top-level links). Tests: 13 new (controller redirects both states, community reflow, all-off dashboard smoke, DashboardVisibility gating); full suite 4,648 runs / 0 failures / 0 errors.cp_feature_flags table + Cp::FeatureFlag model with a closed 5-key set and resolution order ENV override (FEATURE_<KEY>) → cached DB value → false (fail-closed); the compact enabled-keys map is cached and busted on write. Cp::BaseController gains cp_feature_enabled?(feature) (helper, available in portal controllers + views) and a require_feature!(feature) guard (OFF → redirect to dashboard) for use in 20.2/20.3. Champion Admin toggle UI at /champions/feature_flags (portal_admin) with per-flag On/Off, description, and last-changed-by; new “System” sidebar section. Rows are created enabled: !Rails.env.production? (dev/staging unchanged, production launches dark) via the migration data-insert and an idempotent db/seeds.rb block; tests use test/fixtures/cp/feature_flags.yml (all on). This sub-phase is infrastructure only — no features are gated yet. Tests: 15 new (9 model + 6 controller); full suite 4,634 runs / 0 failures / 0 errors./privacy (Public::PoliciesController; nested domain-constrained route placed before the shared cp legal routes so it doesn’t resolve to the Alumni Network’s platform policy), committing that signup data is used entirely within Belmont and never sold. The who_you_are step now collects: a required Privacy Policy agreement checkbox (stored as privacy_policy_accepted_at + privacy_policy_version, versioned via ChampionSignup::PRIVACY_POLICY_VERSION; records predating the policy are asked to agree on their next edit), an optional “keep me in the loop” email opt-in (marketing_opt_in + timestamp), and an optional TCPA text-message consent checkbox with may-call disclosure (sms_opt_in + timestamp; cleared server-side when no phone is given). To keep the step from feeling overwhelming, the SMS consent block is progressively disclosed — hidden until a phone number is entered (new sms-consent Stimulus controller; unchecks itself if the number is cleared), and the policy/marketing checkboxes sit in a light divider-separated section rather than a boxed panel. Opt-in timestamps are stamped on grant and cleared on withdrawal (model callback). The site footer’s Privacy Policy link now points to the site’s own policy instead of belmont.edu’s. Champion Admin signup detail shows a “Consent & Communication Preferences” panel (policy acceptance date/version, email opt-in, text consent) so staff can check before reaching out. Tests: consent checkbox rendering/requirement, acceptance stamping, opt-in defaults and timestamps, SMS-without-phone clearing, edit re-consent for pre-policy records, privacy page rendering, footer link, staff consent-panel states, SMS block hidden/shown states; full suite 4,619 runs, 0 failures.public layout loaded the Google Maps JS API (with Places) on every page for every visitor, but its only consumer, address_autocomplete_controller.js, was referenced by no view (v1 wizard leftover — the v2 flow collects a plain ZIP text field). The CSP script_src didn’t even allow maps.googleapis.com, so the script was already blocked wherever CSP is enforced. Removed the layout block, the orphaned Stimulus controller, and the window.initAutocomplete global in application.js. One less third party receiving visitor IPs pre-consent; the GOOGLE_PLACES_API_KEY env var is now unused and can be dropped from app config.sticky top-0 z-50) and its right side carries the reversed Belmont University logo linking to belmont.edu/alumni (user-swapped; alt text corrected to “Belmont University”). New site-wide footer (public/champion_signups/_footer, rendered by the public layout with a min-h-screen flex column so it pins to the bottom of short pages) modeled on the Alumni Network footer — Belmont logo + address/phone, social icons, © year — with the bottom links matching belmont.edu’s standard set: Legal Notices, Privacy Policy, Non-Discrimination, Title IX. The profile hub’s “← Back to Belmont Alumni” text link was replaced with a “The people behind Alumni Champions” call-out card whose CTA sends visitors to the Meet the Alumni Team page (belmont.edu/alumni/about-the-team.html). Tests: header/footer/call-out coverage in the public controller suite; full suite 4,604 runs, 0 failures.{"q0" => "a", …, "q8" => "d"} (letter values, 7–9 questions — all 1,277 stored values are scoreable letters). The public interest-spectrum fallback did answers.transform_keys(&:to_i), and since every "qN".to_i is 0 the whole hash collapsed to a single surviving answer — so returning v1 users (via the new permanent profile links) saw one area at 100% instead of their real mix. New ChampionQuizService.normalize_answers is the single cross-generation answer parser (v2 "0"-keys, legacy "q"-keys kept distinct as integers; word-valued oldest rows dropped) and now feeds the public spectrum fallback, the quiz current-answer prefill, the final-answer scoring, the staff show-page fallback (replacing its all-letters guard), and handle_question’s merge — which also converges a legacy record’s "qN" keys onto canonical "0".."6" and drops beyond-range leftovers when they retake the quiz. The champion_signups:backfill_interest_scores rake task no longer skips legacy rows: it scores anything normalize_answers can parse (dry run against dev data: 163 of 188 legacy rows get real distributed spectrums) and now writes result_role through explicit enum-value mapping instead of the raw key. Why it wasn’t caught: the fixture’s legacy answers used word values unlike real production letter data, so no test exercised the true legacy shape. Regression tests at the service level (normalize_answers) and through the public flow (legacy q-keyed answers render a distributed spectrum; word-valued rows render empty, not garbage)./champion_signups becomes a sidebar-based dashboard (layouts/signup_admin, champion_signups/_sidebar, reusing the champion-admin-sidebar Stimulus controller) with quick stats, recent signups, top districts, a completion funnel, and a duplicates alert; the filterable list moves to /champion_signups/all. The dead duplicates route/action (no template — would 500) was removed in favor of ?status=duplicates. Navbar dropdown section renamed “Champion Signups” (Dashboard + Signup Insights); Alumni Network admin is now reached via the sidebar’s “Alumni Network Admin” link rather than its own dropdown section. Districts: new champion_signups.district_id (migration backfills 157/158 zip-bearing rows in place), assigned from zip_code on save (ZipCode.lookup, digit-stripped so a spam row’s quote-wrapped zip resolves correctly; self-heals nil districts; never overwrites v1 city/state). ChampionSignup#location_label (“District — Region”, falling back to city/state, then raw zip) now leads every location display — list column, show page, and the admin-notification email’s new Location row. The staff list filters by district via the shared district-autocomplete Stimulus controller + /api/districts (same mechanics as /alumni_search). Spam guard: new SignupHoneypot service — an off-screen website field plus a signed 3-second minimum-time render trap — on the public step-1 and profile-link forms. Tripped submissions get a silent fake success (no record, no email) and are logged with a [honeypot] tag for auditing. Email signature: all three signup mailer emails (welcome, profile-link, admin notification) now render a shared shared/email/_team_signature partial (“The Belmont Alumni Engagement Team”) in place of per-email hardcoded signatures. Mobile: /champion_signups/all gets a sm:hidden tappable-card list (name, v2/status/BUID/duplicate badges, location, date, simple pagination) mirroring /champions/champions/all, with the desktop table under hidden sm:block; secondary filters (Interest Area, Submitted, Affinity) collapse into a “More filters” disclosure that auto-opens when active, leaving Status/Role/District visible by default. One migration (20260709120000_add_district_to_champion_signups, backfills in-place, no rake task needed). Tests: new/updated coverage for district assignment + location_label (model), the honeypot on both public forms (controller), the district filter and dashboard rendering (staff controller), and the unified signature (mailer); full suite 4,601 runs, 12,521 assertions, 0 failures, 0 errors, 3 skips.public layout’s hardcoded <title>Belmont Alum</title> (which silently ignored the content_for :title views were already setting) now yields per-page titles in the standard Page Title | Belmont Alumni Champions format — landing is the bare site name; steps get “Sign Up”, “Your Belmont Groups”, “Where You Are Now”, “Interest Quiz (N of 7)”, “Where Your Interests Lean”, “Your Interests”; the hub is “Your Profile” (STEP_TITLES + page_title_for in the public controller). Emails: the welcome email’s image header band was replaced with the text header format from the profile-link email (uppercase “Alumni Champions” eyebrow + personalized headline); all three signup emails (welcome, profile-link, admin notification) were converted to the shared shared/email/base_styles document pattern (dark-mode support included) and now end with the Belmont branded footer (logo-primary-reversed.png, address, phone, social icons, “You’re receiving this because…”) in HTML and the shared text footer in plaintext; _branded_footer learned an explicit manage_url: nil to omit the preferences link for recipients with no portal account. The alumnichampions.com host/staging detection was extracted from ChampionSignupMailer into AlumniChampionsHost so mailer and model links can’t drift. v1 re-entry codes: new champion_signups.access_code column (unique index, has_secure_token on create, ensure_access_code! lazily backfills pre-column rows) backs a permanent — unlike the 72-hour signed tokens — re-entry link at GET /profile/:code, which adopts the signup into the session and lands on the profile hub (unknown/deleted codes fall back to the “email me a link” prompt). No v1→v2 data migration was needed: both generations share the champion_signups table, the hub renders v1 contact data as-is, and v1 quiz answers already fall back to live interest-spectrum computation. The CSV export gains access_code + profile_link (mail-merge-ready https://alumnichampions.com/profile/CODE) columns, and the staff index source toggle gains a v1 (legacy) pill (virtual source=v1_legacy filter → ChampionSignup.v1_legacy; v2 rows now stamped via ChampionSignup::V2_SOURCE). Signup Insights (GET /champion_signups/insights, staff): top-line counts (total/v2/v1/return visitors/link requests), a v2 optional-section completion funnel (column-derived predicates, so it covers pre-event records), a v1 legacy wizard funnel with a “v1 signups that returned” counter, and an initial-sitting vs. return-visit table backed by a new champion_signup_events table (ChampionSignupEvent.record — fire-and-forget, never breaks the public flow) instrumented on signup creation, every section save (returning = saved >1h after signup), token/access-code return visits (30-minute throttle), and link requests. Production bug root-caused (no code fix): /champion_signups 500 (PG::UndefinedColumn: column "source" does not exist) is the Phase 19 migrations never having been run on production — deploys don’t auto-migrate (no Procfile release: phase); documented in the debug skill + copilot gotchas, fix is heroku run rails db:migrate -a alumni-lookup. Tests: new coverage for access codes/scopes/permanent URLs (model), ChampionSignupEvent, the /profile/:code route + event instrumentation + page titles (public controller), the v1 filter/insights/CSV columns (staff controller), and email header/footer assertions (mailer); full suite 4,576 runs, 12,410 assertions, 0 failures, 0 errors, 3 skips.header-alumni-champions-white.png) instead of plain text. Champions branding on alumnichampions.com: favicons and the PWA manifest (manifest-champions.json, not previously linked from the layout) now point at the -champions icon set instead of -alumni-network; meta-image-champions.png is the Open Graph/Twitter image on both the landing/signup flow and the congrats hub; the site header swapped the Belmont wordmark for the white horizontal Champions lockup. (Hero and congrats-hub logo placement is still being iterated directly by the user — currently on the generic Belmont wordmark rather than a Champions-specific lockup.) Bug fix — dev server is SSL-only: Procfile.dev binds Puma as ssl:// with no plain-HTTP listener, but protocol: "http" was hardcoded for dev.* hosts in 7 places (ChampionSignupMailer#default_url_options/#lookup_url_options, ChampionEmailUrlHelpers#default_url_options/#lookup_url_options, Cp::ChampionMailer#default_url_options, Cp::Champion#invite_url/#community_invite_url) — any image or link built from them connected but hung/returned an empty reply instead of erroring, with no exception to flag it. All switched to https. A related but distinct bug surfaced in the same sweep: Cp::ContentSubmissionMailer#staff_submission_url and Cp::SupportThreadMailer#staff_thread_url pointed at local.test, a host with no DNS record at all (broken regardless of protocol) — repointed to dev.alumnilookup.com:3000, matching every other Lookup Portal link in the app. Cleanup: removed two untracked, unreferenced image files (28F2FCBC216BD19.png/.jpg) that had been dropped directly into public/. Tests: new/updated mailer and model coverage for the corrected URLs (champion_signup_mailer_test.rb, content_submission_mailer_test.rb, support_thread_mailer_test.rb, cp/champion_test.rb); full suite 4,549 runs, 12,266 assertions, 0 failures, 0 errors, 3 skips.DELETE /sign-up/session → champion_signups#reset, 303). Fixed a latent shared/kiosk bug where a tokenless “Get Started” on a device that still held a prior session would silently overwrite the previous person’s record — create-vs-edit on who_you_are is now driven by the profile token (which the hub’s edit links carry), never the session. Emails: the welcome and profile-link emails were redesigned around a shared card layout (Belmont-blue gradient header band, white rounded card, prominent CTA, divided footer) and now send from “Belmont Alumni Engagement” (class-level default on ChampionSignupMailer, overridable via ALUMNI_ENGAGEMENT_FROM) and sign “— The Belmont Alumni Engagement Team”; the profile-link blurb was aligned to the landing copy. Staging-aware links: default_url_options/lookup_url_options now resolve the correct host on staging — staging_app? infers staging from HEROKU_APP_NAME or the LOOKUP_PORTAL_HOST/CHAMPION_PORTAL_HOST staging vars, with an explicit ALUMNI_CHAMPIONS_HOST taking precedence (also set ALUMNI_CHAMPIONS_HOST=staging.alumnichampions.com on the staging app). Tests: new controller tests for the resume banner, the fresh-vs-overwrite create path, and the reset action; new mailer tests for the from address, the matched blurb, and staging host resolution.ChampionQuizService.interest_spectrum(answers) (additive — role_counts/generate_results/calculate_primary_role are untouched, so the authenticated Champion Portal quiz is unaffected) returns each area’s share of answered questions as a rounded percentage, persisted to a new champion_signups.interest_scores jsonb column (default {}) alongside the existing result_role (still the top-scoring area, for backward compatibility). Public flow: the Interests step (role) drops the direct pick-a-role path entirely and becomes quiz-only — if the signup already has answers, it shows the interest spectrum (icon + plain-language area name + percentage bar, no role-title text) with a “Retake the quiz” link; otherwise it prompts to take the quiz or skip for now. The post-quiz reveal (quiz_results) was redesigned around the same shared _interest_spectrum partial (“Where your interests lean”) with a single “Save and continue” CTA back to the profile hub, replacing the old “confirm this role” flow. Orphaned signup_role_options_controller.js (powered the old direct-pick reveal) removed. Plain-language area names: ChampionRoleService.area_name/area_label add “Making Connections,” “Sharing Stories,” “Bringing People Together,” and “Supporting Causes” as the four areas’ public-facing labels; the public UI shows only these (no parenthetical role titles), while the staff filter dropdown still pairs both via area_label. Staff admin: the signup show page gets an “Interest Spectrum” breakdown card (reusing the same partial, with a “leans most toward” icon header) that falls back to computing the spectrum live from answers when interest_scores isn’t stored, so pre-backfill quiz-takers still render a full breakdown; the index gains an Interest Area + Min Interest % filter (COALESCE((interest_scores ->> area)::int, 0) >= min, v2 quiz-takers only). Fixed the staff “Select” assign-alumni link, which used link_to … method: :patch + data-confirm — dead since @rails/ujs was removed in favor of Turbo, silently firing a GET against a PATCH-only route; switched to data: { turbo_method:, turbo_confirm: }. New backfill rake task champion_signups:backfill_interest_scores[dry_run] scores existing v2 signups that have answers but no interest_scores (skips legacy pre-v2 records, whose answers use word-valued "q0" keys rather than quiz letters). Tests: new service tests for interest_spectrum/area_name/area_label, rewritten public-flow controller tests for the quiz-only Interests step and spectrum reveal, new staff controller tests for the interest filter, the computed-fallback breakdown, and the Turbo-PATCH assign link; full suite 4,538 runs, 12,214 assertions, 0 failures, 0 errors, 3 skips.belmont_experience step is dropped entirely (we already have education from the alumni record; its belmont_background column is retained for legacy records + staff export/email). Step 1 (who_you_are) now completes the signup — it fires the welcome + admin emails immediately (guarded so later contact-info edits don’t re-send) and redirects to the new profile hub. The three remaining sections (affinities, where_you_are, role/Interests) are optional and each return to the hub. The multi-stage progress bar is removed (_progress_bar.html.erb deleted). Congrats/Profile hub (show.html.erb rewritten): adaptive first-visit “You’re in!” vs. return-visit “update your info” copy, a 3-section progress bar, optional-section cards with done/Add states, a contact-info edit link, and a BUID-gated education-record confidence note (“We have you down as a [College] grad”). Access gating: the hub now requires the signup session or a valid short-lived (:profile_access, 72h) signed token; unauthorized/expired/guessed-ID access redirects to the landing “email me a link” prompt instead of exposing PII — closing the prior /sign-up/:id-by-raw-ID enumeration hole. Return access: the welcome email carries a tokenized profile link, and a new landing-page form + POST /profile-link (request_link) + ChampionSignupMailer.profile_link_email let an existing Champion request a fresh link, always responding generically (?link_sent=1) to prevent email enumeration. Landing copy: “What is an Alumni Champion?” rewritten; the four role cards replaced with the four interest areas as examples (“Ways Champions show up”); role-framed CTAs reworded. Model: profile_token / find_by_profile_token, optional-section predicates (affinities_provided?, professional_info_provided?, interests_provided?, optional_sections_completed_count), and find_active_by_email. Deferred to BACKLOG: the ~3-day nudge email and a manual 4-slider interest-rating path; the role→interests step rename happens in 19.8. No migration. Tests: rewrote public controller suite for hub-and-spoke + access gating + request-link, added mailer + model tests; full suite 4,532 runs, 12,183 assertions, 0 failures, 0 errors, 3 skips.signup_token is present. Token hardening: final role completion redirects carry a signed token, and confirmation CTA rendering verifies token ownership against the displayed signup. Staff queue expansion: added filters for role status, BUID link status, affinity data presence, submission recency buckets, contactability, and specific affinity code. Staff show v2 data section: surfaces source, college_code, major_code, industry, job_title, affinity_other, and affinity_codes, with graceful unknown-affinity fallback (raw code when no mapping exists). Tests: expanded coverage in public and staff controller suites; full suite 4,522 runs, 12,126 assertions, 0 failures, 0 errors (3 skips).steps/_affinities.html.erb) now reuses the cp-affinity-selector Stimulus controller for searchable, category-filterable affinity selection (Geographic and Post Graduation categories excluded, matching the profile wizard); affinity codes submit as top-level affinity_codes[] hidden inputs and persist to champion_signups.affinity_codes. The step also captures a free-text “other” group (affinity_other) and a belonging reflection (belonging_note, “Where did you feel the most belonging at Belmont?”). Per product direction, this flow intentionally does not capture lifestage_interest or belonging_categories. Source tagging: new signups are stamped source='champion_signup_v2' at step 1 creation. Submission/mailer: completing the final role step (both “confirm a role” and “skip for now” paths) enqueues two emails via deliver_later — a personalized welcome_email to the new Champion (role copy delegated to ChampionRoleService, generic fallback when no role) and an admin_notification to CHAMPION_SIGNUP_ADMIN_EMAILS summarizing all fields, source, BUID match status, and a staff review link. ChampionSignupMailer is restored with per-instance default_url_options (Alumni Champions domain for public links, Lookup Portal domain for the staff link) so its host settings never leak into other mailers. Staff queue: the signup index gains a v2 source badge and a Source filter dropdown. No migration required — all columns shipped in 19.1. Tests: public controller (57 runs) + new mailer + staff filter coverage; full suite 4,514 runs / 0 failures.signup_role_options_controller.js to reveal role options on demand. Fixed a session edge case where selecting a role could return users to landing by adding a signed signup_token fallback path in the controller when session state is missing. Added regression coverage; public signup controller tests now 44 runs / 0 failures.steps/_role.html.erb) is the first interactive page after the profession step: it shows all four role cards (using inner role icons, not full seals), a “Take the quiz” CTA for the unsure, and direct “Choose this role” buttons. After taking the quiz, the recommended role is highlighted with a “Recommended for you” badge and a “Confirm this role” button. Quiz (steps/_quiz_question.html.erb, reused for question1–question7) renders one question per page with options shuffled per render; answers persist to champion_signups.answers (jsonb) and back-navigation returns to the prior question (or the hub from Q1). Quiz results (steps/_quiz_results.html.erb) computes the recommendation via ChampionQuizService.generate_results, persists result_role, shows the role seal + narrative + full blend breakdown, then routes back to the role hub to confirm (“show results, then role”). Light Interests (steps/_affinities.html.erb) replaces the stub with interest-category checkboxes (belonging_categories) plus an optional free-text note (belonging_note); all fields optional. The confirmation/thanks page (show.html.erb) now celebrates the chosen role with its seal, title, and description. Controller adds handle_question, handle_role (finalizes selected_role, clears session, redirects to confirmation), and handle_affinities (light persistence), with the shared quiz partial wired via @partial_step. Reuses existing ChampionQuizService, ChampionRoleService, ChampionSignupsHelper, and role_seal_svg/role_icon_svg. Flow: who_you_are → belmont_experience → affinities (Interests) → where_you_are (Job) → role hub → [optional: question1–7 → quiz_results → role] → confirmation. Tests: public controller 42 runs / 0 failures; full suite 4,499 runs / 0 failures.You → Belmont → Interests → Job → Role) — Updated the public signup UX to reduce friction and support phased QA. Step 1 (Who You Are) now captures graduation year(s) as free text and makes ZIP required (server-side enforced), with ZIP placed ahead of optional phone. Step 2 (Belmont Experience) now uses a single free-text belmont_background field for college(s)/major(s), replacing normalized selectors; this better handles renamed schools, older degree paths, and multi-degree alumni. Step 3 (Where You Are) is now profession-only (employment status + company/job/industry) and no longer conflates role selection. Added company column on champion_signups and signup_employment_status_controller.js for conditional field behavior (employed / seeking / not_working). Added affinities and role stub pages so the intended order is navigable while full 19.3/19.4 work continues. Landing and role cards now use inner role icon assets rather than full seal-in-seal rendering. Progress bar duplicate render bug fixed. Tests: public controller 30 runs / 0 failures; full suite 4,487 runs / 0 failures.alumnichampions.com/sign-up. Step 1 (Who You Are): name, an optional previous/maiden name behind a toggle-reveal, graduation year (a 1955→current-year dropdown), email, and optional phone + ZIP — this step creates the ChampionSignup record and stores session[:champion_signup_id]. Step 2 (Belmont Experience): a college → major cascade plus an “I never graduated from Belmont” option that clears both fields; majors are embedded in the page as JSON and filtered client-side by a new college_major_filter Stimulus controller (the staff-authenticated GET /api/majors endpoint would 401 in the public flow). Step 3 (Where You Are): industry + job title, then a three-way role path — “I know my role” (pick → role), “Help me decide” (quiz → question1), and “I’ll decide later” (skip → affinities) — implemented as three separate forms with a role_path Stimulus controller mirroring the shared industry/job-title inputs into each form’s hidden fields and handling pick-panel role-card selection. Controller: handle_who_you_are / handle_belmont_experience / handle_where_you_are step handlers with per-step data loaders, plus IMPLEMENTED_STEPS (unbuilt steps fall back to the landing page) and STEPS_REQUIRING_SIGNUP (signup-dependent steps redirect to who_you_are without a session) guards. Shared _error_summary partial renders validation feedback. Email-format and major_code inclusion validations are deferred to 19.4. Tests: 12 new controller tests (27 in file); full suite 4,484 runs, 0 failures.alumnichampions.com. New Public::ChampionSignupsController < PublicController lives in the public/ namespace (not the staff champions/ namespace), keeping the audience separation explicit. Domain routing is now properly isolated: a nested constraints block ensures /sign-up routes are only reachable from alumnichampions.com — belmontalum.com/sign-up 404s cleanly. alumnichampions.com/ root overrides the Devise anonymous landing so visitors land on the signup page, not the alumni portal. Schema: 7 new columns on champion_signups — college_code, major_code, industry, job_title, affinity_codes (jsonb, default []), affinity_other, source — plus an index on source for staff filtering in Phase 19.5. Restored config/initializers/champion_signup.rb (admin emails from CHAMPION_SIGNUP_ADMIN_EMAILS env var). Views: Branded _header.html.erb; 4-stage _progress_bar.html.erb; full _landing.html.erb education page with hero, role cards (seals + activity lists), and dual CTAs. Host config: alumnichampions.com, staging.alumnichampions.com, www.alumnichampions.com added to production allowed hosts; alumnichampions.com added to test environment. Tests: 15 runs, 32 assertions, 0 failures.Degree.writes_frozen? (constant Degree::WRITES_FROZEN = true) now gates every path that created Degree rows. Csv::AlumniImporter.import_degrees is a no-op that returns { created: 0, frozen: true }; Csv::BannerImporter#commit_rows still creates/updates the Alumni record but skips the degree branch (incrementing skipped_degrees); Settings::AlumniController#import_degrees_commit skips both the degree-create and date-repair writes while keeping alumni create/update, and #import_degrees redirects with Degree::FROZEN_MESSAGE. The legacy Degree upload page and Banner import page show amber “frozen” banners pointing to the Educations CRM import. Reads are unaffected — Alumni::EducationProfile still falls back to degrees for alumni without educations rows.?source=legacy|education stats toggle, the coverage banner, and the aggregate_stats_v2:* cache namespace are all removed. EngagementStats::BaseService, OverviewService, DemographicsService, and ActivityPairsService no longer accept a source: argument — they always read through Education::AggregateScope and cache under engagement_stats_*. StatisticsController and EngagementStatsController dropped @source / education_source? / the legacy branches; EngagementStatsController#calculate_top_alumni_data_optimized now filters college/year via AggregateScope.buid_subquery; the dead fiscal_year_sql helper (referenced degrees.degree_date) was deleted. Both shared/_stats_coverage_banner and shared/_stats_source_toggle partials are deleted. Every per-record alumni.degrees read was migrated to Alumni::EducationProfile or Education::AggregateScope: the engagement-stats alumni tables and alumni/top_engaged (year/degree/college columns), the stats year-filter dropdown, the Champion verification and search-result education snippets, the Champion show page recent-degree block, Cp::CommunityDetectionService (college/major detection + member finders), Cp::Community#eligible_for?, Cp::HomeHelper grad years, Cp::AlumniLikeMeService college/major/year scoring, Csv::AlumniExporter UG/GR classification, Csv::EventRsvpConverter + Tools::EventConverterController enrichment, Champions::CommunitiesController grad year, and LegacyVerificationService#match. The remaining filter joins were also migrated: Alumni.filter_by_name’s year filter, Cp::DirectoryController#load_colleges / #grad_years_by_decade, Cp::CareerConnectService cluster matching, and Cp::CommunityMatchingService.find_matching_champions (college + major) now read through AggregateScope. Data-driven (non-display) reads pass Privacy::NONE to avoid unintended redaction; display reads keep inferred privacy. The EducationProfile→degrees fallback and EducationCoverageService’s raw Degree counts intentionally remain on Degree until the legacy degrees table is dropped under a later cleanup tag (coverage ≥ 99%). Regression test added for find_matching_champions matching an Education-only champion with no Degree row.<meta refresh> that displayed stale zeroes until the job finished. New education_import_batches.processed_count column tracks rows processed so far. EducationImportBatch#start_scanning!/#start_applying! reset processed_count; new #progress_percent (0–100, capped) and #record_progress! (cheap update_columns write of only the supplied keys) back the poller. Csv::EducationImporter and Csv::EducationAreaOfStudyImporter accept an optional progress: callback threaded through preview and commit_rows, invoked every 200 rows (constant PROGRESS_STRIDE) plus a final exact count. EducationImportScanJob reports scan progress (learns total_rows from the first callback); EducationImportApplyJob sets total_rows to the manifest row count up front and reports created/updated/skipped live. New JSON endpoint GET /settings/alumni/education_import_batches/:id/status (status_education_import_batch) mirrors the Affinaquest status action. A new CSP-safe Stimulus controller (import_progress_controller.js) polls the endpoint (~1.5s), updates the bar/counts, and reloads the page when the batch leaves the in-progress state so the server can render the preview table, results summary, or error. Backed by model tests (progress reset + progress_percent cap + record_progress! selective writes), importer progress-callback tests, and controller status-JSON tests.education_areas_of_study.major_code + major filter migrated off Degree — New nullable, indexed major_code column on education_areas_of_study, sourced from the CRM “Area of Study: External Id” field (the program/major code, valid for majors, minors, and concentrations). Csv::EducationAreaOfStudyImporter resolves it by header name (the CRM places “Area of Study: External Id” between the two “Area of Study: Area of Study Name” columns and Contact, so parsing is keyed off header names rather than fixed positions to stay resilient to column ordering), upcases/trims it, surfaces it in the import preview table (new “Major Code” column), persists it on commit, and includes it in the no-op comparison so re-imports stay idempotent. With a stable code now on educations, Alumni.filter_by_major was migrated from the frozen degrees table to match alumni via education_areas_of_study.major_code joined through educations — the alumni search/stats major dropdown (which already passes major_code) now matches directly, no name fuzzing. This was the last read path still on degrees other than the intentional EducationProfile→degrees fallback. Backed by importer tests (External Id → upcased major_code, persistence, no-op re-import, nil when column absent) and filter_by_major model tests (matches major + concentration by code, returns none for unknown code, ignores blank).Alumni::EducationProfile::Entry now carries an areas_of_study collection (new AreaOfStudy value object with name + level) built from each awarded education’s EducationAreaOfStudy rows, plus an areas_of_study_summary helper that renders a grouped, labeled line ordered major → minor → concentration with pluralized labels (e.g. Majors: Music, Marketing • Minor: Spanish • Concentration: Jazz). The summary appears under each education entry on the Lookup alumni profile Degrees card and the Champion Portal directory profile Education card (respecting existing education-privacy redaction). Degree-fallback entries carry no areas of study, so the line only renders for Education-sourced records with area detail. Backed by presenter unit tests covering multi-area grouping, pluralization, level ordering, and the degree-fallback/no-area nil cases.educations + Csv::CurrentStudentImporter retired — New educations.expected_graduation_year (integer) and educations.student_status (string) columns hold per-enrollment in-progress data so a BS-holder enrolled in an MS shows the MS program independent of the rolled-up alumni.student_status. New Education.in_progress scope (date_issued IS NULL and student_status either NULL or 'pending') is the single source of truth for current enrollment; new Education::ENROLLMENT_COLLEGE_CODE_SQL constant centralizes the COALESCE that resolves an in-progress row’s college code. Alumni#currently_enrolled? (independent of awarded), Alumni#current_student? (currently enrolled AND no awarded degree — the “Almost Alumni” case), and Alumni#current_enrollment_education / current_enrollment_grad_year / current_enrollment_school_code / current_enrollment_program readers all delegate to the new scope. Alumni.currently_enrolled and Alumni.current_students class scopes route through Education.in_progress. The Lookup Portal alumni profile, alumni search results, champion verification page, and Champion Portal profile-wizard “help find you” step all now read from the most recent in-progress education record instead of the denormalized alumni.current_* columns. Csv::EducationImporter now applies the one-way awarded ratchet on alumni.student_status and, when an awarded education row lands, clears the legacy alumni.expected_graduation_year / intended_degree_code / current_program_desc / current_school_code so any lingering readers see a consistent state until those columns are dropped in deploy 2. Csv::CurrentStudentImporter and its entire UI surface (controller, two background jobs, manifest store, batch model, status views, settings nav link, rake task, batch backfill migrations) are deleted in favor of the single CRM-sourced education feed. The alumni.current_school_code / current_program_desc / intended_degree_code / expected_graduation_year columns remain in the schema for this deploy; their drop migration and removal of Csv::AffinaquestContactImporter#clear_enrollment_fields_if_awarded! are deferred to a follow-up deploy after the next CRM import has populated the new educations columns for the live in-progress roster.Education::AggregateScope + side-by-side toggle) — New query object at app/services/education/aggregate_scope.rb is the single source of truth for “Education-first w/ Degree fallback” aggregations. A UNION ALL subquery emits one normalized row per awarded credential (buid, date_issued, degree_code, degree_level derived via CASE in SQL, college_code, major_desc, major_code, source). Per-BUID Education-first wins; Degree branch surfaces only BUIDs with no educations row. Aliased as educations so Education.from(from_clause).where(col: val) qualifies columns correctly. Alumni.with_degrees, without_degrees, filter_by_college, filter_by_fiscal_year, and filter_by_year now all route through AggregateScope.buid_subquery. EngagementStats::BaseService, OverviewService, and DemographicsService accept source: :education | :legacy and switch SQL paths accordingly (legacy uses degrees.degree_date joins; education uses inlined AggregateScope columns). EngagementStatsController and StatisticsController both accept ?source=education|legacy and thread the choice through services, filter forms (hidden field), tab links, and cache eviction. Education path caches under a new aggregate_stats_v2_* namespace; legacy path remains on engagement_stats_* (cache will be collapsed in 18.8). New shared/_stats_coverage_banner and shared/_stats_source_toggle partials render on both /statistics and /engagement_stats for staff parity validation. CP partial sweep: cp/profile_wizard/_step_confirm_education, cp/careers/_career_connect_cards, and the three cp/leadership partials (community, welcome_message, members) now render through Alumni::EducationProfile instead of raw alumni.degrees. Alumni.filter_by_fiscal_year lenient parsing accepts 24, "24", "FY24", and 2024. Backed by 11 parity tests covering UNION shape, per-BUID dedup, college/fiscal-year filters, degree-level buckets, top-major filtering (concentrations/minors excluded), AR-embeddability, and lenient FY parsing.Phase 18.5 — Lookup API backwards compatibility + Data Health dashboard — Api::V1::AlumniSearchController#serialize_alumni and the legacy Api::AlumniController#index typeahead now route through Alumni::EducationProfile for all education-derived fields while preserving the V1 response contract (every existing key unchanged). Both endpoints emit a new advisory _source field ("education" |
"degree_fallback" |
"none") so consumers can observe the rollout. New EducationCoverageService exposes education_coverage_pct, legacy_fallback_pct, and a broad unmapped_school_name_count (Education granting schools + Areas-of-Study current institutional units whose code is blank and whose free-text name does not match any colleges.college_name or college_name_short). New internal /api/v1/education_coverage endpoint and admin /settings/data_health dashboard render the same metrics. The dashboard includes an expandable drill-down table of distinct unmapped school names (with occurrence count, source, and a sample BUID/source ID) and a CSV download so the data team can triage them in bulk. Stopgap rake task bin/rails alumni:legacy_fallback_buids prints BUIDs still on legacy degrees (front-end filter for this roster is tracked for Phase 18.7). |
Alumni::EducationProfile) — New presenter at app/services/alumni/education_profile.rb is the single source of truth for derived education data on an Alumni record. Education-first with Degree fallback per alumni: if any Education rows exist, derive from Education + EducationAreaOfStudy; otherwise synthesize from degrees -> majors -> colleges. Exposes value objects (Privacy, Entry, CurrentSchool) and rollup methods (undergraduate, graduate, all_entries, recent, college_codes, major_codes, grad_years, display_summary, to_export_hash). Entry#degree_level is derived at read time via Education.level_for(degree_code) for resilience against fixtures and stale rows. Privacy is auto-inferred from alumni.champion.education_privacy unless an explicit viewer: matches the displayed champion (self-view never redacts). Backed by 23 unit tests + 5 parity tests asserting Degree-sourced vs Education-sourced output is identical for the same data.Alumni#recent_degree and Alumni#graduation_years now delegate to the presenter; AlumniHelper#generate_checkin_data rewritten to call to_export_hash (single export contract for the event check-in copy button); AlumniHelper#degree_border_color now accepts either a Degree or Entry. Lookup Portal views (alumni/show, alumni/search) and core Champion Portal views (cp/directory/_champion_card, _recommendation_card, cp/directory/show, cp/profile/show) swapped to iterate presenter entries. Matching services (Cp::CommunityMatchingService, Cp::AlumniLikeMeService, Cp::CareerConnectService) read college/major rollups through the presenter. API V1 serializer is unchanged — Entry#as_json preserves the contract ({major_desc, college_name, degree_code, degree_date} with college_name mapped to college_name_short to match the legacy SQL alias). API serializer migration is deferred to Phase 18.5.[YYYY-MM-DD] (or [YYYY-MM-DD – YYYY-MM-DD] when activities span multiple days), pulled from MIN(engagement_date) / MAX(engagement_date). Type headers now include the unique-description count (e.g. Email Click (2,150 alumni • 47 unique descriptions)) so the cardinality of each activity type is visible at a glance. Descriptions are sorted by most-recent date (max_date DESC); the first 25 render inline and a native <details> “Show all N descriptions (X more)” disclosure reveals the rest.EngagementStats::BreakdownService capped the per-type description list at the top 10 by row count, so for activity types with many distinct descriptions (e.g. event_attended over a full fiscal year) any description beyond the 10th was hidden from the UI. This caused legitimate doubt about whether engagement data was missing from the database. Removed the .limit(10) so all distinct descriptions render. Type-level and level-level unique-alumni counts (which are computed from the full filtered scope, not the descriptions array) were always correct and are unchanged. Added regression test covering 15 distinct descriptions plus min/max date fields and ordering.Csv::EducationImporter, Csv::EducationAreaOfStudyImporter) write to educations and education_areas_of_study with idempotent upserts on (buid, source_education_id) and person_area_of_study_id. Uploads enqueue background jobs (EducationImportScanJob → EducationImportApplyJob) backed by a persistent EducationImportBatch (Zlib-compressed CSV + manifest in DB binary columns, mirroring CurrentStudentImportBatch). The status page polls every 5 seconds while in progress, then shows a preview table and a commit button when scanned. Per-batch gap CSVs are stored on the batch and downloadable for the lifetime of the record. Listed under “Data Imports” (not “Archived Imports”) in the settings sidebar.buid, contact_id, … so Advancement Services can cross-reference rows against BruinQuest contact records without a manual lookup. AoS rows resolve BUID/BQID via the parent Education -> Alumni join.20260423090000_seed_granting_school_colleges populates 29 known historical/current college codes so granting-school resolution by alias or colleges.college_name works on first import.EducationAreaOfStudy::CONCENTRATION_LEVELS now includes "unknown". Rows with blank or unrecognized concentrations import as "unknown" and surface in the gap CSV instead of being silently dropped.educations and education_areas_of_study tables with DB-level foreign key from educations.buid to alumni.buid, idempotency-friendly unique indexes, and Alumni#educations / Alumni#education_areas_of_study associations.Education and EducationAreaOfStudy models — Auto-derive degree_level from degree_code and area_of_study_name_normalized from raw text on save; downcase concentration_level; optional College associations for granting/current school codes. 31 model tests added.docs/planning/qa/PHASE_18_LAUNCH_GUIDE.md with migration checklist, FK behavior notes, sub-phase deployment ordering, and rollback plan.docs/planning/phases/phase-18/samples/README.md documenting the awarded-degrees and areas-of-study CSV inputs expected before 18.3 import work.upload_degrees page now links out to the new “Educations CRM” and “Areas of Study CRM” import flows alongside the legacy Banner/Degree importers (which remain unchanged in 18.3).set_fixture_class education_areas_of_study: EducationAreaOfStudy so the unconventional table-name fixture loads correctly.Education#degree_level values to use undergraduate, masters, and doctorate (plus unknown fallback), replacing graduate/professional labels.script_src now allows https://cdn.jsdelivr.net and https://cdnjs.cloudflare.com so importmap-pinned libraries (Flowbite, Chart.js, @kurkle/color, Trix) load without CSP violations. Consolidated duplicate connect_src declaration that was overriding the first.EngagementStatsController#clear_cache was matching stale glob patterns (engagement_stats_overview_fast_*, breakdown_data_fast_*, etc.) left over from before the EngagementStats::*Service refactor. Current service keys use the prefix engagement_stats_<service>_… (no _fast_ segment), so the button updated the “last updated” timestamp but never evicted any cached data — stale stats stayed live until the 1-hour TTL expired. Replaced the patterns with anchored regexes (/\Aengagement_stats_/ and /\Atop_alumni_data_fast_/) that match the actual service-written and legacy controller-written keys, and added a regression test that writes representative keys, hits the endpoint, and asserts they’re evicted while unrelated keys survive. Also: MemoryStore#delete_matched (used in production) expects a Regexp, not a glob — passing globs silently coerced * to “zero or more underscores”.scanned state, so the meta-refresh wasn’t rendered and the Commit button stayed visible. A second click then tripped the “Batch is not ready for commit (status: completed)” guard. The controller now flips the batch to running before enqueueing the apply job (the job’s own start_applying! is idempotent), and an in-progress/completed batch returns a friendly notice instead of an alarming alert.data-turbo-method and data-turbo-confirm attributes so delete actions consistently execute.AlumniFilterService, and search view./new-grads on all Alumni Portal domains now 301 redirects to belmont.edu/alumni/new-grads.:complete; sub-phases 6.2/6.3 → :backlog.docs/planning/event-checkin-integration/ (7 files). These planning docs for the standalone alumni_events app are no longer needed in this repository.BackNavigationHelper. Maps ~50 controller/action combos to logical parent pages (iOS-style predictable navigation). Replaces 31 hand-coded inline render 'shared/back_link' calls with a single layout-level helper. Public/unauthenticated views suppress the back link via content_for(:hide_back_link). Views can override via content_for(:back_link_path) + content_for(:back_link_text)./settings/affinaquest/crm_changes page was showing records from Affinaquest imports (recency conflicts) alongside app-originated changes. These import records already came from the CRM and shouldn’t be flagged for export back. Added app_originated scope to CrmDataChange; applied to listing, export CSV, mark-exported, action item badge count, and CrmDataExportBatch.export_all_pending!. Import records are still created for field protection tracking — they just no longer appear on the export page.to_champion_attributes field mapping (9 fields), added legacy data notice to staff signups index, updated CHAMPION_SIGNUP_SYSTEM.md with retirement notice and field mapping table./signups/* multi-step wizard (controller, views, mailer, initializer, rake task, tests). Replaced with 301 redirects to /champions. Preserved: ChampionSignup model, staff management interface, data conversion pipeline in ConfirmationsController, SVG partials used by staff views. 2 redirect tests added./ landing page with two-tier messaging. Replaced abstract pillars (Belong/Gather/Inspire) with concrete feature cards (Alumni Directory, Communities, Events, Conversations). Added “For Alumni Who Want to Do More” Champions teaser section linking to /champions. Removed inline footer (layout footer handles it). 10 tests, 22 assertions./champions serving as the redirect target for alumnichampions.com. Educates anonymous visitors about the Alumni Champions program with adapted content from the authenticated /champion-info page. Includes hero, three engagement tiers (Member/Champion/Community Leader), four role cards (informational only), benefits, social proof stats, How It Works, and signup/signin CTAs. Social proof counts true Champions (role selected) not just verified portal members. OG meta tags for social sharing. 10 tests, 26 assertions.
Cp::ChampionsLandingController — extends ApplicationController (public, no auth), redirects signed-in users to /champion-infoGET /champions route in public scope (between policy routes and authenticated routes)primary_role presentscan_complete! and complete! prevent a finishing job from overwriting a cancelled status.manifest_data column on the batch tables instead of Redis. Eliminates Redis OOM on large CSV imports — the 50k-row Affinaquest import no longer needs Redis capacity for manifest storage. Redis is now reserved for Sidekiq and Action Cable only.csv_content column on the batch record, passing only the batch ID to scan jobs. Previously the full CSV string (~8MB for 49k rows) was passed as a Sidekiq job argument through Redis, causing OOM before the scan even started.apply_manifest_to_batch now processes changes in 500-record chunks instead of loading all records and alumni into memory at once. Per-chunk alumni preloads, key transforms, and GC runs keep worker memory under 512MB for 49k-row imports.start! didn’t reset stats from scan preview. Both start! methods now reset all counters to 0 before the apply phase begins.GC.start(full_mark: true) + GC.compact after all scan/apply jobs complete, and csv_content cleanup in apply jobs. Set MALLOC_ARENA_MAX=2 on both staging and production to reduce glibc arena fragmentation.:update instead of :skip. Fixed by checking fields_to_update.empty? only, ignoring recency conflicts for the action decision.:update with an empty changes hash. Now builds changes hash first, checks if empty, and returns :skip before marking :update.created_count column on current_student_import_batches (migration)create_alumni_from_row builds records with BUID, names, enrollment fields, birthdate, and contact info (zip, phone, emails, maiden name)increment_created!, updated complete!, scan_complete!, total_processed, summary on batch modelbirthdate, date_of_birth, and dob CSV columns with recency-aware updates and US-format date parsingbuild_alumni_per_college now uses preloaded College.pluck hash instead of .group().count which produced flat hash keyscurrent_students scope updated to include NULL expected_graduation_year records (5,442 → 7,646)Date.parse misinterpreted MM/DD/YYYY as DD/MM/YYYY — now uses Date.strptime("%m/%d/%Y") first with fallbackACTIVITY_GROUPS constant on EngagementType — no migration neededdelete_filtered route and controller actionapply_population_filter in AlumniFilterService using SQL UNION for structurally incompatible scopesPOST /api/v1/engagements/batch): External systems (e.g., alumni_events) can batch-record engagement activities via API. Idempotent via event_identifier — safe to re-submit.can_portal_admin users, not just role: portal_admindefault-src 'self', Google Fonts whitelisted, unsafe_inline for Tailwind CSS, Google OAuth and Apple Sign-In in form_action, nonce generation enabledapply_permissions_policy before_action: camera, microphone, geolocation, payment, USB, gyroscope, magnetometer, accelerometer, MIDI, autoplay, picture-in-picture set to :none; fullscreen to :selfstrict-origin-when-cross-origin) added to production environmentconfig.force_ssl = truetest/integration/security_headers_test.rbeducation_show_all, education_hide_year, education_hidden) with privacy enforcement across directory, profile, career connect, and champion card views
AccountDeletionService: Soft-delete workflow — clears 30+ PII fields, anonymizes discussion posts to “Former Member”, nullifies associations, sends confirmation emailDataExportService: Generates JSON export of all personal data (profile, education, communities, connections, activity history, privacy settings)active and deleted scopes on Cp::Champion modelCp::CanSpamHeaders concern adds List-Unsubscribe and List-Unsubscribe-Post headers to 7 non-transactional mailersshared/email/_text_footer.text.erb) with physical address, unsubscribe link, and reason textage_confirmed attribute with acceptance validation on createage_confirmed_at timestamp recorded on successful registrationskip_consent_validation?education_privacy_updated, account_deleted, data_exported/dashboard in favor of /home: Removed the legacy DashboardController and all its views; /dashboard now redirects to /home
_community_notifications, _invited_community, _role_card, _role_card_ideas) from cp/dashboard/ to cp/home/refresh_role_ideas action and turbo stream template to HomeController (route: POST /home/refresh_role_ideas, same helper cp_refresh_role_ideas_path)GET /dashboard now returns 301 redirect to /home (preserves bookmarks)DashboardController (337 lines) and show.html.erb (878 lines)progressive_dashboard feature flag from all 3 environment configsdashboard_role_card_test → home_role_card_test, updated backward-compat tests to assert redirecttrack_click_controller.js Stimulus controller fires non-blocking POST via fetch with keepalive: trueCp::RoleIdeaClicksController endpoint records clicked_role_idea_cta and clicked_contextual_cta activity eventsclicked_contextual_cta event type in Cp::ActivityEventchampions/insights/role_ideas) updated with tabbed layout (Role Ideas / Contextual Prompts), hide-zeros toggle, role attribution for contextual promptsinsights_tabs_controller.js Stimulus controller for tab switching and zero-row filteringcommunity.id to community.slug in ContextualPromptGenerator CTA routes to avoid 301 redirects that Turbo couldn’t followneeds_age_confirmation? method to Cp::Champion that detects OAuth users without age_confirmed_at, and updated check_policy_consent to redirect these users to the consent page with an age confirmation checkbox.NoMethodError: undefined method 'role' in DataExportService#community_data by replacing role with membership_type (based on primary flag).next_incomplete_wizard_step returning non-existent "privacy" step. Now returns nil when all wizard steps are complete (even if wizard_completed_at not formally set). Updated after_sign_in_path_for in session/OAuth controllers to auto-complete wizard when no steps remain./terms, /privacy, /community-guidelines, /cookies (converted from docs/compliance/drafts/ with internal cross-links resolved to in-app routes)cp_policy_acceptances table: Audit trail recording champion ID, policy type, version, acceptance timestamp, and IP addressCp::PolicyVersion model: Central config with CURRENT_TERMS / CURRENT_PRIVACY version constants; consent_current? and stale_policies class methods drive the re-consent checkCp::PolicyAcceptance model: Immutable audit record with validations; record_for_champion! creates acceptance entriesterms_accepted attribute validated on create; OAuth registrations exempt via skip_consent_validation?)cp_champions: terms_accepted_at, terms_version, privacy_accepted_at, privacy_version capture last-accepted version and timestampCp::ConsentController): check_policy_consent before-action in Cp::BaseController redirects stale champions to /consent; acceptance records both policies and resumes intended destinationpolicy_consent_updated event type added to Cp::ActivityEventdocs/compliance/LEGAL_REVIEW.md): Comprehensive 26-section pre-launch legal assessment covering data collection, user consent, privacy controls, FERPA/COPPA, CAN-SPAM, third-party services, and 18 outstanding gaps. Updated to reflect Phase 16 implementation status — 6 gaps marked complete.docs/compliance/IT_SECURITY_REVIEW.md): Full security assessment covering authentication, encryption, rate limiting, session security, headers, and deployment. CSP (#1) and security headers (#4) recommendations marked implemented via Phase 16.5.docs/compliance/drafts/TERMS_OF_SERVICE.md): Full ToS draft ready for legal review. 18+ age requirement, connection-gated messaging, UGC licensing, governing law (Tennessee). 2 items flagged for legal input.docs/compliance/drafts/PRIVACY_POLICY.md): Full Privacy Policy draft ready for legal review. Covers data collection, retention, deletion (30-day commitment), user rights, 18+ minimum age. 1 item flagged for legal input.docs/compliance/drafts/COMMUNITY_GUIDELINES.md): Full Community Guidelines draft with expected behavior, prohibited content, connection-gated messaging rules, moderation process, and appeals.docs/compliance/drafts/COOKIE_POLICY.md): Cookie Policy documenting single essential session cookie; confirms no analytics/tracking cookies in use.docs/planning/phases/phase-16/README.md): Full implementation spec for 6 compliance sub-phases (16.1 policy pages + consent, 16.2 education privacy, 16.3 account deletion + export, 16.4 CAN-SPAM, 16.5 security headers, 16.6 age attestation). Includes cross-document link table and traceability matrix.app/controllers/champions/roadmap_controller.rb): Phase 16 — Legal & IT Compliance added with all 6 sub-phases; status: planned.anyone / limited / nobody.alumnilookup.com removed from public-facing docs: Internal staff portal domain withheld from ToS and Privacy Policy. Retained in internal IT/Legal review documents./champions/insights; legacy Role Ideas page now redirects into Insights./deploy skill, copilot-instructions.md, and CLAUDE.md. Ensures docs stay current during ad-hoc feature/bugfix work (not just phase wraps).display_name) instead of bare city name.district: (slug param) with an ID value; corrected to district_id: so the search actually filters by district./dashboard instead of root; now correctly routes to /home.email, email_school, email_personal, email_business, email_other) to the alumni search CSV export. The email column uses a best-email fallback: stored email first, then school email (current students only), then personal, business, and other./tools/legacy_verification for processing Admissions legacy CSV files. Extracts relative names and relationships from free-text “Legacy Details” column, matches against alumni database using multi-strategy search (direct, middle-name maiden fallback, applicant-last-name fallback, geographic, fuzzy), and exports results CSV with confirmation status and matched BUIDs.ready_to_explore_at milestone and /home/start_exploring action so alumni in profile_building stage can advance when only optional profile items remain./champion-info, /roles, /roles/:role, quiz results, profile wizard role step, and microsite nav with inline SVG role icons (hexagonal badges) and seals (circular badges with role name). Icons render directly without circle wrappers for proper sizing. Seals used in standalone/hero contexts. Added role_seal_svg helper to ApplicationHelper. Documented icon vs seal usage rules in DESIGN-GUIDELINES.md.All Alumni & Almost Alumni vs Limit Who Can Contact Me mode in both profile wizard and settings. Limited mode now uses 3 explicit restriction checkboxes (communities, city, almost alumni).contact_mode_controller so mode switching updates card highlighting in real time and disables limited-mode checkboxes when All Alumni is selected./home) instead of profile, with tests updated to match.301 redirects in Champions::VerificationsController#show when a champion record still uses numeric to_param, restoring 200 responses for verification detail views while preserving canonical slug redirects for slugged records.belmont_stories:import task now prints structured per-item results (imported/skipped/backfilled/failed) with quiet mode (QUIET=1) for scheduled runs; 3 rake task testsDate.strptime; added normalize_date_text with multi-format fallback and backfill for existing posts missing published_at; 3 new scraper testsitems_for_sections method replaces items(limit:) — applies independent per-section max/min (news: 4, discussion: 3, photo_album: 4) for balanced home page content; 2 new service tests/home: Authenticated root now always points to Cp::HomeController#show; removed progressive_dashboard feature flag conditional from config/routes.rb; all cp_dashboard_path references (70+) updated to cp_home_path across 20 controllers, 10 views, 3 layouts, 12 test files; /dashboard route preserved for backward compatibility/dashboard to /homerole_color (fountainblue, skyblue, belmontblue, belmontred) replaces hardcoded amber for contextual prompt borders, backgrounds, icons, badges, and CTA buttons_primary card partial_compact to _standard card with bar chart icontarget="_top" to lazy-recommendations turbo-frame elements in engaging, champion, and community_leader stages; added data-turbo-frame: "_top" to career resources link — fixes Career Resources and Alumni You Might Know sections disappearing on clickNoMethodError for undefined cp_community_news_index_path → cp_community_path(community, anchor: "news")app/views/cp/home/_whats_new_bar.html.erb): Compact bar below hero showing counts of new content (discussions, articles, events, photo albums) since last dashboard visit; click-through links to each content index; human-friendly “since” labels (earlier today, yesterday, day name, date); hidden on first visit or when nothing newapp/services/cp/milestone_service.rb, app/models/cp/milestone.rb): 8 milestone types (first_community, profile_complete, first_connection, first_post, champion_opt_in, one_year_anniversary, ten_connections, community_leader); auto-detection on dashboard load; unique per champion; celebration banner with warm copy; max 1 per visit; auto-dismiss after display; milestone_achieved activity event trackingcreate_cp_milestones: New table with champion_id, milestone_type (integer enum), achieved_at, displayed_at; unique index on (champion_id, milestone_type)app/views/cp/home/sidebar/_connections.html.erb): Shows connected people (photo, name, “View” link to profile) instead of message threads; progress indicator for <3 connections; empty state “Meet a fellow alum” with directory linkapp/views/cp/home/sidebar/_communities.html.erb): Per-community colored badges showing new post counts (blue) and upcoming event counts (amber); tooltips with counts; icons on desktop, hidden labels on mobile; batch-loaded via controller to avoid N+1exploring stage onward (was champion+); added to _exploring.html.erb and _engaging.html.erb stage partialsmilestone_achieved/champion-info): Educational “What & Why” page with warm, zero-pressure tone; 3 sections (What is a Champion, Three Engagement Tiers, What Changes When You Opt In); conditional CTA (has role: summary card → role detail; no role: Explore Roles + quiz link); canonical source language from identity statement/champion-info — Educational overview (What & Why), no inline role management/roles — Explore & Choose page with rich role cards, direct “Choose this role” selection, quiz CTA/roles/:role — Role detail + management (retake quiz, change role, remove role with confirmation)_champion_microsite_nav.html.erb) across all 3 pagesmax-w-3xl container with standardized padding across all pageschampion-info/select-role and champion-info/remove-role endpoints; added DELETE /roles/remove to RolesController; all entry points updated (dashboard role card → /roles/:role, profile show → /roles/:role, wizard referrers → role detail, nudge “Learn More” → /roles)app/services/cp/champion_nudge_engine.rb): 5 trigger definitions (content_submitted ≥1, active_discussant ≥3 comments, active_connector ≥5 connections, profile_complete ≥100%, community_active ≥2 posts); JSONB-based impression tracking via seen_tooltips; max 3 impressions before auto-suppression; permanent opt-out after 3 dismissalsapp/views/cp/home/_champion_nudge.html.erb): Amber-accented inline card on engaged_member stage with dual CTAs (Find Your Role → quiz, Learn More → roles page) and session dismiss; turbo-frame wrapped for clean removalset_champion_nudge_flash helper in base controller; only shown to Tier 1 Members (not Champions or CLs)app/controllers/cp/nudges_controller.rb): Session dismiss and permanent suppress actions with activity trackingviewed_champion_info, nudge_shown, nudge_dismissed, nudge_clicked_quiz, nudge_clicked_learn_more, role_changed, role_removedapp/services/cp/contextual_prompt_generator.rb): Role-aware, data-driven prompt generation from live community data; 8 generator methods (new members, unconnected alumni, quiet communities, albums needing photos, unanswered discussions, quiet led communities, upcoming events, low-visibility events); dismissal tracking via seen_tooltips JSONBresolve_contextual_cta_route helper: Resolves string-format route references (e.g., "cp_community_path:123") to actual URLsRoleIdeaPackServiceapp/views/cp/home/stages/_new_members_to_welcome.html.erb): CL-specific partial showing recent members (14 days) who haven’t been welcomed yet; avatar, name, community, “Say Hello” CTA; welcome tracking via cl_welcome_sent activity events_community_leader.html.erb after community health cardboards/show.html.erb, boards/_comment.html.erb) — previously only added to global discussion viewsbg-#{color}/10) with explicit ROLE_BADGE_CLASSES class map to prevent build-time purging30.days.ago event fixture coincided with travel_to date, causing false positive email sends_stages/onboarding.html.erb): “What to Do Next” checklist with per-item routing to specific wizard steps/profile sections; smart hero CTA path (@next_wizard_step → @next_profile_section → fallback)_stages/exploring.html.erb): Community type grid (4 types explained), “Recommended for You” suggestions with type labels + member counts, Browse Communities CTA_stages/engaging.html.erb): Activity feed, suggested communities with type labels + member counts + descriptions, dismissable career resources card (Turbo Frame, JSONB tracked)engaged_member stage; shows “X% complete · Edit →” or “Profile complete ✓”Cp::Tierable concern (app/models/concerns/cp/tierable.rb): engagement_tier (:member/:champion/:community_leader), compute_journey_stage (8 stages: new_arrival → community_leader), recompute_journey_stage! (persists to DB), has_contributions? (board posts, news, events, comments, 5+ reactions), tier predicatesadd_journey_stage_to_cp_champions: integer column with index on cp_champions.journey_stage, default 0, not nullCp::DashboardVisibility service: maps journey stage → visible main/sidebar sections; tier-aware sidebar ordering; feature-flagged selective data loadingjourney_stage:recompute_all: backfill all existing champions’ cached journey_stageCp::HomeController: parallel to existing DashboardController; feature-flagged root; selective data loading (13 categories, only queries what the stage needs); activity recording + journey recomputation on loadGET /home → cp_home_path; feature flag config.x.features.progressive_dashboard (all envs set to false — gradual rollout)app/views/cp/home/: show.html.erb orchestrator, _hero.html.erb (8 stage variants), 3 card tier partials (Primary/Standard/Compact), 7 sidebar partials, 5 stage partials (onboarding, exploring, engaging, champion, community_leader)Cp::HomeHelper (app/helpers/cp/home_helper.rb): thread_other_champion(thread, champion) with multi-API fallback to shield views from model API changesturbo_frame: "_top".preload instead of .includes prevents SQL WHERE from filtering eager-loaded degreesdeliver_later created EmailLog dedup records asynchronously via Sidekiq; if digest job ran multiple times before delivery, dedup check found nothing and re-sentdeliver_later → deliver_now in Cp::NotificationDigestJob for both daily and weekly digestsCp::EmailLog now works reliably since interceptor writes log synchronously14.1 — Data Model & Backend Foundation
submitted: -1 and declined: -2 status enums on Cp::NewsPost and Cp::Eventsubmitted_by_champion_id, decline_reason, submitted_at, declined_atCp::ContentSubmissionThread model (polymorphic content, open/resolved status)Cp::ContentSubmissionMessage model (polymorphic sender, body, read_at tracking)14.2 — Champion Submission Forms & UX
/news/submit (Cp::NewsSubmissionsController)/events/submit (Cp::EventSubmissionsController)?community_id= parameter/my-submissions (Cp::SubmissionsController)14.3 — Admin Submissions Queue & Review
/champions/content_submissions with tab-filtered index14.4 — Notifications & Attribution
submission_reply, submission_promoted, submission_published, submission_declinedCp::ContentSubmissionMailer with 3 email flows (new submission, champion reply, published)bg-skyblue/30 for sender’s bubbles, bg-white border for recipient’stext-gray-700/text-gray-800)Tests: 3,582 runs, 9,178 assertions, 0 failures
cp_email_logs table with automatic logging via EmailLoggingInterceptor/champions/email_logs (index, show, stats, CSV export)Cp::EmailLog model with scopes and analytics methodsdigest_mention_count on thread participants; threads become “stale” after 2 mentions, shown only when fresh content existscp_email_logs to prevent duplicate sends (scheduler double-fire protection)mark_read! and new message callbacks reset digest_mention_count to 0db:prepare replaces db:create + db:schema:load.deliver_later directly (Rails 7.1 mail header restriction)Tests: 3436 runs, 0 failures
offset parameter, total_count, and has_more fields in API response envelopetmp/banner_uploads/ to prevent garbage collection between preview and commit requestsTests: 3389 runs, 0 failures
GET /api/v1/alumni/search endpoint with Bearer token API key authenticationApiKey model with key generation, authentication, per-minute rate limiting (100/min default), and revocationfilter_by_fuzzy_name and filter_by_fuzzy_full_name scopes on Alumni modelAlumniMatcher (exact search first, pg_trgm fallback on no results)api_keys:create, api_keys:list, api_keys:revoke, api_keys:statsdocs/features/ALUMNI_SEARCH_API.mdcl_welcome connection type for Community Leader welcome messagescommunity_id column on cp_connection_requests for CL context⭐ CL · CommunityName +N (desktop), ⭐ CL (mobile)allows_contact_from?cl_welcome_sent activity event typeUSER_CONNECTION_TYPES constant (excludes system-only types from user settings)expandable_text_controller.js Stimulus controller for click-to-expand textTests: 3319 runs, 0 failures
Belmont Alumni <alumni@mail.belmontalum.com> (was Belmont Alumni Champions <champions@mail.alumnichampions.com>)<h1> text updated from “Belmont Alumni Champions” / “Alumni Champions” to “Belmont Alumni”alt="Alumni Champions" → alt="Belmont Alumni" on header imagesTests: 3318 runs, 0 failures, 0 errors
*.belmontalum.com as alternate Champion Portal domain (routing, host allowlists, verification)belmontalum.com, www.belmontalum.com, beta.belmontalum.com, staging.belmontalum.com, dev.belmontalum.combelmontalum in hostnameTests: 3318 runs, 0 errors
Full audit of Champion Portal user-facing text against 20 canonical source documents. 20 of 25 findings implemented across 7 waves; 5 explicitly deferred.
Cp::RoleIdeaPackService generates 3 role-aligned action suggestions daily per Champion. Features: weighted random selection by priority, 30-day anti-repeat window, timezone-aware date rollover, Community Leader variant (mixed pool of role + CL-specific ideas)./roles page showing all 4 Champion roles (Community Builder, Digital Ambassador, Connection Advisor, Giving Advocate) with descriptions from canonical source documents. Includes “Discover Your Role” quiz CTA.role_ideas:seed, role_ideas:stats, role_ideas:cleanup_old_packs.refreshed_role_ideas activity event.Cp::RoleIdea — Content model with role, target, status enum (draft/active/paused/archived), priority weighting, template variable interpolation (, , )Cp::RoleIdeaPack — Daily pack per champion per date with refresh_count trackingCp::RoleIdeaPackItem — Join model with position ordering (primary at 0, secondary at 1+)viewed_role_card, clicked_take_quiz, clicked_learn_more_roles, refreshed_role_ideas, clicked_role_idea_cta, role_selectedChampionContact model deleted. FAQ and UI updated.event_type in EngagementScoreService#point_value to prevent NoMethodError on nil.Cp::Connection — Canonical pair ordering (champion_a_id < champion_b_id), required message_thread, connected_at/disconnected_at timestamps, disconnect! methodCp::ConnectionRequest — Statuses: pending/accepted/ignored/cancelled. Types: say_hi/career_advice/networking. Rate limiting via sent_today_by/received_today_by scopesCp::ChampionContact — Replaced entirely by Cp::Connectionthread_type column (connection/support) with corresponding scopes and methodscontact_preference enum with connection_preferences JSONB. Added connection associations, preference methods, and connected_champion_ids cache# Run AFTER deployment (idempotent — safe to re-run)
heroku run bin/rake connections:migrate_contacts --app alumni-lookup
tz.name) to IANA identifiers (tz.tzinfo.identifier) to match stored DB valueswhitespace-nowrap flex-shrink-0 to count badge pills to prevent awkward line wrapsdismiss_welcome! now preserves original timestamp on repeated calls (fixes flaky CI test)"daily" to "immediate" so new champions receive prompt email notifications for unread messages/careers) — Public landing page with self-serve-first career resources
career_event: trueresource_downloaded activity event with resource_id and category metadata/champions/career_resources)
pdf_dropzone_controller.js Stimulus controllercareer_event boolean on cp_events table
Cp::Event.career_events scope/champions/stats/career_center
career_center_view and resource_downloaded trackedcp_career_resources table with Active Storage PDF attachment
after_create Callback — New communities automatically receive 2 seeded discussion posts:
SeededQuestionSelector with deduplicationSeededQuestionPublisher with community-aware interpolationRails.logger.infocommunity_seeded_discussions_test.rb covering callback conditions, method behavior, and integrationdependent: :destroy — Changed has_one :seeded_question_exposure from dependent: :nullify to dependent: :destroy to fix NotNullViolation crash when deleting board posts (the board_post_id column has a NOT NULL constraint)config/welcome_packs.yml with 15 community types,) for personalization_welcome_panel.html.erb):
dismiss_welcome_controller.jscp_champion_communities join table:
welcome_dismissed_at (datetime) — When member dismissed the panelcommunity_view_count (integer) — Tracks visits for auto-dismissaldismiss_welcome controller action with activity event trackinglib/tasks/welcome_content.rake:
welcome_content:generate_all — Generate for all communitieswelcome_content:generate[community_id] — Generate for specific communitywelcome_content:preview[community_type] — Preview templates (supports HTML=1 flag)welcome_content:status — Show generation status across all communitieswelcome_content:clear[community_id] — Clear welcome contentwelcome_content:reset_dismissals[community_id] — Reset member dismissalswelcome_content:generate_type[type] — Generate for all of a community typewelcome_content_generator_test.rb — Service generation and template selectionwelcome_content_rake_test.rb — All rake task functionalitycommunity_memberships)/champions/insights/onboarding — Signup funnel and wizard completion/champions/insights/seeded_questions — Community suggestion performanceseeded_questions:create to SCHEDULERS.md:
test/tasks/seeded_questions_rake_test.rb):
seeded_questions:create and seeded_questions:statsseeded_questions:stats task — Fixed NoMethodError: undefined method 'active':
Cp::SeededQuestion.active to Cp::SeededQuestion.status_activestatus enum uses prefix: true, so scopes are status_active, not activeseeded_questions:preview task — Fixed wrong column name:
cp_community_id to community_id (BoardPost schema uses community_id)seeded_questions:create task — Fixed wrong column name (previous session):
cp_community_id to community_id at line 26app/views/champions/seeded_questions/):
resources :seeded_questions with preview, activate, deactivatecollege_desc → college_name attribute name in CommunityDetectionServicefor_wizard scope to use integer enum values (0, 1, 2) not string nameschampions/events/show.html.erb — Full event detail view with breadcrumbs, status, cover image, date/location cardschampions/news_posts/show.html.erb — News post detail with hero, content cards, featured champions, engagement statssm:hidden mobile cards + hidden sm:block desktop tables:
rounded-md sm:rounded-lg (8px mobile → 12px desktop)px-4 sm:px-6 lg:px-8 wrappers from ~14 view files (layout provides these)flex-col sm:flex-row for mobile-friendly vertical stackingline-clamp-2 with proper flex container constraintsshow action for news_posts and events (was except: [:show])Settings → Data Imports → Current Students):
current_students table):
active flag for current enrollment statusCurrentStudentManifestStore (matches pattern in ManifestStore and Sidekiq)config/initializers/rack_attack.rb):
/wp-admin, /xmlrpc.php, etc.)?screenshotCacheBust= parameterlib/middleware/request_logger.rb):
[SLOW REQUEST] tag[SCREENSHOT SERVICE] tag[SCANNER DETECTED] taglib/tasks/security.rake):
security:status — Show Rack::Attack configuration statussecurity:rules — List all security rulessecurity:analyze_tips — Heroku log analysis commandsunread_messages_count and unread_notifications_count on championsundefined method 'build_full_url' error in join_request_approved.html.erb by adding the helper method to CommunityMailernew_message notifications now excluded from bell dropdown entirely (they have their own indicator in the Messages nav item)beta.alumnichampions.com — Production beta (live data)staging.alumnichampions.com — Staging environmentdev.* and localhost — Developmentalumnichampions.com and www.alumnichampions.com now redirect to the legacy signup flow (Squarespace marketing site)beta.alumnichampions.com) — Blue banner, production app with live datastaging.alumnichampions.com) — Amber banner, staging app with test datadev.*, localhost) — Development indicatorbeta_environment? alongside existing staging_environment?schema_migration.versions instead of querying ActiveRecord::SchemaMigration directlymigrate:export_cp — Exports all Champion Portal tables as SQL INSERT statementsmigrate:export_cp_summary — Preview record counts before migrationprimary column) with proper quotingverify:environment_comparison — Compare hosts, mailer config, env vars, table statsverify:db_schema_comparison — Compare migration versions between environmentsverify:post_cutover — Automated health checks after domain cutoverdb:snapshot — Outputs table counts, max IDs, latest timestamps by categorystaging.alumnilookup.com and staging.alumnichampions.comalumnichampions.com and www.alumnichampions.com (Squarespace marketing site)featured_photo method to respect position order — dragging a featured photo before another featured photo now correctly makes it the new coverChampions::PhotoAlbumsController/photo-albums index: Gallery listing all published albums with cover images/photo-albums/:slug detail: Album detail page with responsive photo gridreturn unless Time.zone.today.monday? guard to Cp::WeeklyDigestJob — Heroku Scheduler only supports 10-min/hourly/dailyverification_pending_notification — When champion signs up, awaiting staff verificationverification_approved_notification — When champion is verified by staffsupport_reply_notification — When staff replies to support threadcommunity_leader_assigned_notification — When champion is assigned as CLbuild_full_url method for consistent asset URLsnormalize_buid callback to convert empty strings to nil before save, preventing PG::UniqueViolation on faculty/staff signup without BUIDlib/tasks/notification_digests.rake with convenient tasks for QA testing:
bin/rake notifications:digest_recipients — List champions with digest notifications configuredbin/rake notifications:digests:preview_daily[email] — Preview daily digest without sendingbin/rake notifications:digests:preview_weekly[email] — Preview weekly digest without sendingbin/rake notifications:digests:send_daily_to[email] — Send daily digest to specific championbin/rake notifications:digests:send_weekly_to[email] — Send weekly digest to specific championbin/rake notifications:digests:send_daily — Send daily digests to all eligible championsbin/rake notifications:digests:send_weekly — Send weekly digests to all eligible (bypasses Monday check)docs/qa/EMAIL_TESTING_GUIDE.md with comprehensive documentation for testing all 10 mailer classes and 23+ email types in developmentCp::WeeklyDigestJob since Heroku Scheduler doesn’t support weekly scheduling (only 10-min, hourly, daily). Job now runs daily but only sends emails on Mondays./directory/contacts route for contacts-only listingnew_contact setting)contact_added, contact_removed tracked in activity eventsunread_messages_count, unread_notifications_count on cp_championsalumni, almost_alumni, faculty_staff on cp_championsanticipated_program, anticipated_college_code, anticipated_graduation_datework_email, profession, affiliated_college_code, affiliated_programcommunity_id when switched to Global visibility and clears author fields when set to Engagement Team, preventing stale data from lingering after edits.can_support_respond permission when feedback submittedportal_admin role AND can_support_respond permissionapp/models/cp/feedback.rb — Model with validations, scopes, helpersapp/controllers/champions/feedbacks_controller.rb — Admin CRUD controllerapp/views/champions/feedbacks/index.html.erb — Filterable list viewapp/views/champions/feedbacks/show.html.erb — Detail view with status managementdb/migrate/*_create_cp_feedbacks.rb — Database schematest/models/cp/feedback_test.rb — 24 model teststest/controllers/champions/feedbacks_controller_test.rb — 14 controller testsCp::LandingController with champion/city statsunauthenticated :cp_champion root now shows landing pagerequire_champion_verified! guardscommunity_connection_copy, community_private_copy for standardized messagingpb-20 sm:pb-0 padding pattern_back_link.html.erb shared partial with consistent chevron patternscroll_to_selected_controller.js for selected pill visibilityw-full sm:w-auto pattern for mobile-first buttonsw-48 thumbnail on right_champion_card.html.erb: 64×64 → 56×56 (matches w-14)directory/show.html.erb: 128×128 → 112×112 (matches sm:w-28)events/show.html.erb: 800×500 → 800×450 (matches aspect-video 16:9)_step_photo.html.erb: 200×200 → 160×160 (matches w-40)_discussion_card_compact.html.erb: Square → rectangular variants_event_card_compact.html.erb: Square → rectangular variants_news_card_compact.html.erb: Square → rectangular variantsnational: true flag for special behaviordiscussion_reply, discussion_reaction, community_post/insights/discussion_boards with comprehensive metricsEngagementStats::DiscussionBoardsService for metrics calculation (45+ tests)/discussions route)_news_card_compact, _event_card_compact, _discussion_card_compact_event_row with date badge styling_event_row)max-w-7xlmax-w-7xlCp::DiscussionsController tests_public_cta_box.html.erb with content-type-aware copy
max-w-4xl (896px) regardless of page containerpublic_display_name: Champion model method returns “FirstName L.” format for privacy/champions/discussions interface for staffreaction_added event for analytics(reactions × 2) + (comments × 3) + (time_factor × 10) with 14-day decayapp/views/cp/boards/_reactions.html.erb — Reusable reaction bar partialapp/views/cp/boards/_post_card.html.erb — Reusable post card with hot badgeapp/javascript/controllers/board_reactions_controller.js — Stimulus controllercp_board_posts, cp_board_comments, cp_board_reactions, cp_post_flags, cp_user_blocks, cp_hidden_contents, cp_moderation_actionsCp::BoardPost, Cp::BoardComment, Cp::BoardReaction, Cp::PostFlag, Cp::UserBlock, Cp::HiddenContent, Cp::ModerationActionnational boolean to cp_communities for auto-join behaviordiscussion_board_enabled boolean to cp_communitiesindex and show actions for viewing discussionsboard_view and post_view for analytics/champions/community_leaders/new) now shows engagement scores for each championcommunity_leader_assignedCp::Notification::NOTIFICATION_TYPES and DEFAULT_EMAIL_FREQUENCIESnotify_community_leader_assigned(assignment:) method in Cp::NotificationServicecommunity_id column to cp_clc_assignmentsregion_id column (previously unused)[cp_champion_id, community_id] to prevent duplicate assignmentsassigned_at (auto-set) and assigned_by_id tracking columns/champions/community_leaders — List all CL assignmentsportal_admin or admin roleCp::Champion#profile_completion_percentagebin/test Wrapper Script:number_of_processors)PARALLEL_WORKERS, TEST_TIMEOUT, SKIP_CLEANUP, MAX_STALE_MINUTESTEST_TIMEOUT=60 for slower testsPARALLEL_WORKERS + 2 connectionscommunity_leaders_controller.rb new/create actions for multi-selectcommunities/show.html.erb member list with CL badges and assign links@existing_cl_champion_ids to communities controller show actioncommunity_leaders/new.html.erb with dual-form structure (champion or community context)community_leaders/index.html.erbcalculate_profile_completion_stats to Stats controllerchampions_helper.rb to delegate to model methodtest_helper.rb with timeout and parallel worker configurationbin/test wrapper script with cleanup logicOmniauthCallbacksController didn’t specify a layout, defaulting to application.html.erb (Alumni Lookup)layout 'champions' to OmniauthCallbacksControllerapp/controllers/cp/omniauth_callbacks_controller.rb: Added layout declarationremember_me cookie)Cp::OmniauthCallbacksController:
handle_oauth() now stores champion ID in session and redirects to remember choiceoauth_remember_choice() action shows the dialog (GET /oauth/remember-choice)oauth_confirm_signin() action completes signin with remember_me flag (POST /oauth/confirm-signin)app/views/cp/omniauth_callbacks/oauth_remember_choice.html.erb
app/controllers/cp/omniauth_callbacks_controller.rbconfig/routes.rbconfig/initializers/devise.rb (timeout increase)app/views/cp/omniauth_callbacks/oauth_remember_choice.html.erbAddresses: OAuth session timeout too short, unclear “Remember Me” UX for OAuth logins
default_url_options method that reads CHAMPION_PORTAL_HOST from environmentbuild_full_url helper to convert relative paths (stored in DB) to full URLshttp://dashboard/ → https://beta.alumnichampions.com/dashboardhttp://communities/... → https://beta.alumnichampions.com/communities/...TYPES_WITH_DEDICATED_MAILERS constant to NotificationServicejoin_request_approved and join_request_deniedFiles Modified:
app/mailers/cp/notification_mailer.rbapp/services/cp/notification_service.rb*.bualum.co → alumnilookup.com (dev/beta/prod: dev.alumnilookup.com, beta.alumnilookup.com, alumnilookup.com)*.bualum.co → alumnichampions.com (dev/beta/prod: dev.alumnichampions.com, beta.alumnichampions.com, alumnichampions.com)email.bualum.co → mail.alumnichampions.comconfig/routes.rb — Now matches alumnichampions in host, champions.* prefix, or champions subdomainconfig/environments/test.rb — Added host authorization for all test domainsdocs/operations/DOMAIN_MIGRATION.md with complete migration checklistcp_notifications table with aggregation support (count, last_aggregated_at)cp_notification_preferences table for per-type settings (in_app_enabled, email_frequency)Cp::Notification model with scopes (unread, for_champion, recent_first)Cp::NotificationPreference model with DEFAULT_PREFERENCES for 9 notification typesnotification_preference_for(type) methodnew_message — New messages in threads (aggregates per thread)new_member — New members in your communities (aggregates per community)new_event — New events in your districtcommunity_suggestion — Suggested communities to joinjoin_request_received — Someone requested to join your community (CLC only)join_request_approved — Your join request was approved (immediate email)join_request_denied — Your join request was denied (immediate email)verification_pending — Account verification in progress (immediate email)verification_approved — Account verified (immediate email)Cp::NotificationService with smart aggregation for high-volume typesCp::NotificationJob for async notification creationCp::NotificationDigestJob for daily (7am) and weekly (Monday 7am) email digestsCp::NotificationCleanupJob for removing read notifications older than 30 daysCp::NotificationMailer with immediate_notification, daily_digest, weekly_digestnotifications_controller.js Stimulus controllerCp::NotificationsHelper for icon/styling helpersMessagingService creates new_message notificationsMessageNotificationJob updated to use new preferences modelCp::MessagesHelper with link detection and extraction methodscontains_community_links? detects community URLs in message textextract_community_slugs extracts community slugs from URLsstrip_community_urls removes URLs for clean display_community_card.html.erb partial renders preview cardsclipboard_controller.js Stimulus controller for copy functionalitycommunity_share_message helper generates friendly share textinvited_community_id and invited_by_id columns on cp_championsCp::MessagesHelperTestcp_join_requests table for tracking join requests to private/custom communitiesCp::JoinRequest model with pending/approved/denied status enumapprove!(admin, note:) creates membership and sends approval emaildeny!(admin, note:) requires explanation and sends denial emailsource column to cp_champion_communities: self_joined, approved, auto_matched, admin_added, invitationadded_by_id foreign key to track who added membersremoved_at, removed_by_id, removal_reasonvisibility enum to communities (visibility_public, visibility_private)invite_token for private community invite linksCp::CommunityMailer#join_request_submitted — Notifies Engagement TeamCp::CommunityMailer#join_request_approved — Notifies championCp::CommunityMailer#join_request_denied — Notifies champion with explanationextract_city_state for parsing addressesformatted_event_date_time using event’s formatted_time method/public/bg-campus.jpg background image to Champion Portal layoutvenue_street, venue_city, venue_state, venue_zipCp::Event methods: city_state, city_state_zip, full_addressfull_address for display and Google Maps linkscommunities.any?globally_visible? is true/public/logo-horizontal-reversed.svg logo/directory?district=xxx parameter mismatch (now uses district_id)district_id, college_code, major_code, affinity_code, industrystatus_champion_verified?)cp_community_notifications table with Champion and Community referencesnotification_type enum (currently threshold_reached)unread, recent, for_dashboardcreate_threshold_notification!, notify_community_members!notification_dismissed event)ChampionVerificationService → triggers college/major detectionProfileWizardController → triggers on location, profession, affinities stepsProfileController → triggers on location, profession, affinities section edits/communities/:slugcommunity_view event)/communities| New nav order: Communities | Directory | Messages | [Name] |
/champion_admin/communities/communitiescp_events table with full event support
cp_event_districts join table (mirrors News pattern)
event_viewed and event_rsvp_clicked event typesCp::EventCp::EventDistrictCp::EventsControllerFiles Created:
db/migrate/*_create_cp_events.rbdb/migrate/*_create_cp_event_districts.rbapp/models/cp/event.rbapp/models/cp/event_district.rbapp/controllers/champions/events_controller.rbapp/views/champions/events/*app/controllers/cp/events_controller.rbapp/views/cp/events/*app/javascript/controllers/event_*.js (3 controllers)test/models/cp/event_test.rbtest/models/cp/event_district_test.rbtest/controllers/cp/events_controller_test.rbtest/fixtures/cp/events.ymltest/fixtures/cp/event_districts.yml@mail.alumnichampions.com domain
Cp::ChampionMailer: champions@mail.alumnichampions.comMessageMailer: champions@mail.alumnichampions.comApplicationMailer: noreply@mail.alumnichampions.comalumnichampions.com (reserved for Heroku app)MAILGUN_DOMAIN=mail.alumnichampions.com in Herokuadmin_recipients query to properly find portal_admin and admin users
%i[portal_admin admin] to string array ['portal_admin', 'admin']image_carousel_controller.js for multi-image posts
Files Created:
app/javascript/controllers/image_carousel_controller.jsFiles Modified:
app/views/cp/news/show.html.erb — Added carousel for multiple imagesapp/views/cp/news/_post_card.html.erb — Image count badge, aspect ratio, pin iconapp/views/champions/news_posts/_form.html.erb — District autocomplete, multipart formapp/javascript/controllers/news_post_images_controller.js — Drag-drop/paste supportCp::NewsController with index and show actions/news) and show pages (/news/:slug)
hero_image methodCp::NewsPost records replace sample data
load_news_posts method using visible_to(champion) scopenews_viewed activity event
views_count counter cacheFiles: app/controllers/cp/news_controller.rb, views in app/views/cp/news/
Settings::NewsPostsController with full CRUD + publish/unpublishportal_admin and admin access via ensure_portal_admin!Key fixes during implementation:
inverse_of to news_post_regions association for proper nested buildsto_param)Files: app/controllers/settings/news_posts_controller.rb, views in app/views/settings/news_posts/
cp_news_posts — Main posts table with ActionText, polymorphic author, counter cachescp_news_post_regions — Join table for regional targetingcp_news_post_likes — Champion likes with counter cachecp_news_post_views — View tracking with source/referrer/durationcp_clc_assignments — CLC role assignments to regionsCp::NewsPost — Scopes: published, draft, global, regional, visible_to, for_region, pinned_firstCp::NewsPostLike — Counter cache updatesCp::NewsPostView — Source tracking (dashboard, email, search, etc.)Cp::NewsPostRegion — Join modelCp::ClcAssignment — CLC role management with Champion helper methodsChampion CLC Methods: is_clc?, clc_for_region?(region), clc_region_names, clc_regions
Deferred (schema-ready, UI deferred): Image carousel, college/major/affinity tags, featured champions
Complete planning specification for admin-managed news/posts system with dashboard integration.
docs/planning/phases/phase-1/1.10-community-news.md
/news/:id/:slugDeferred to future phases (schema ready):
Cross-references updated:
app/controllers/champions/roadmap_controller.rb — Added 1.10 sub-phasedocs/planning/phases/phase-1/README.md — Added 1.10 to tabledocs/planning/BACKLOG.md — Added 1.10 deferralsdocs/planning/phases/phase-1/1.9-pre-beta-polish.md — Updated Phase 3.3 refs to 1.10docs/planning/development/DESIGN-GUIDELINES.md — Updated Phase 3.3 ref to 1.10Comprehensive voice and tone guide for Champion Portal copy based on the Alumni Champions Verbal Style Guide.
docs/planning/development/LANGUAGE_STYLE_GUIDE.md
new_message_notification.html.erb → “Connect with fellow Bruins and grow our Champion community”new_message_notification.text.erb → Same updatechampion_mailer/invite.html.erb → “Share stories and celebrate wins”champion_mailer/invite.text.erb → Same updateFiles Created:
docs/planning/development/LANGUAGE_STYLE_GUIDE.mdFiles Modified:
app/views/message_mailer/new_message_notification.html.erbapp/views/message_mailer/new_message_notification.text.erbapp/views/cp/champion_mailer/invite.html.erbapp/views/cp/champion_mailer/invite.text.erbapp/views/cp/directory/index.html.erbapp/controllers/cp/settings_controller.rbapp/controllers/cp/profile_wizard_controller.rbSimplified, scannable directory cards focused on connection.
[icon] degree_code Major (YYYY)DirectoryHelper#college_icon_svg[college icon] degree_code Major (YYYY)includes(degrees: { major: :college })Files Modified:
app/views/cp/directory/_champion_card.html.erbapp/views/cp/directory/index.html.erbapp/views/cp/profile/show.html.erbapp/views/cp/directory/show.html.erbapp/helpers/cp/directory_helper.rbSelf-service help resources for Champions to find answers without contacting support.
/help route with comprehensive FAQaccordion_controller.js)faq_search_controller.js)config/faq.ymlContext-sensitive tooltips guide new Champions through key features.
seen_tooltips JSONB column tracks dismissed tooltips per Championtooltip_seen?, mark_tooltip_seen!, pending_tooltips, tooltip_forCp::TooltipsController with dismiss endpoint (POST /tooltips/:key/dismiss)tooltip_dismissed events for analyticsonboarding_tooltip_controller.js — Stimulus controller for display/dismissCp::TooltipsHelper for easy view integrationFiles Added:
config/faq.yml — FAQ content and tooltip definitionsapp/controllers/cp/help_controller.rbapp/views/cp/help/index.html.erbapp/javascript/controllers/accordion_controller.jsapp/javascript/controllers/faq_search_controller.jsapp/javascript/controllers/onboarding_tooltip_controller.jsapp/controllers/cp/tooltips_controller.rbapp/helpers/cp/tooltips_helper.rbdb/migrate/20251225172229_add_seen_tooltips_to_cp_champions.rbtest/controllers/cp/help_controller_test.rbtest/models/cp/champion_tooltip_test.rbtest/controllers/cp/tooltips_controller_test.rbComplete admin interface for managing the Champion program with visibility into Champion activity and engagement.
/champions — Staff dashboard listing all Champions/champions/:id — Detailed Champion profile view with complete activity timelineNew activity-based scoring to measure Champion engagement and identify active community members.
Cp::EngagementScoreService — Reusable service for score calculationdocs/features/champion_portal/ENGAGEMENT_SCORING.mdensure_portal_admin! guard for dashboard access (staff, portal_admin, admin roles)docs/features/champion_portal/ENGAGEMENT_SCORING.md — Complete guide to engagement system
Complete 1:1 messaging system for Champions to communicate within the portal.
cp_message_threads — Conversation containers with message count trackingcp_message_thread_participants — Per-user thread state (read, muted, archived)cp_messages — Individual messages with soft delete supportcp_message_reactions — Emoji reactions on messagesCp::MessagesController — Full CRUD for messaging (index, show, new, create, reactions)Cp::MessagingService — Thread management, message sending, reactionsMessageNotificationJob — Background job for 5-minute delayed email notificationsMessageMailer — Email templates for message notificationsmessage_thread_started and message_sentverified_at, and region/city filters have been removed./champions)New Portal Admin section for Champion Program management with dedicated layout and navigation.
Champions::BaseController — Base controller with ensure_portal_admin! guardChampions::ChampionsController — Dashboard with quick stats, verification funnel, regional breakdownChampions::VerificationsController — Queue for verifying champions by linking to alumni BUID
champion_verified statusChampions::StatsController — Placeholder for detailed statistics (Phase 1B/6)layouts/champion_admin.html.erb — Dedicated layout with sidebar navigationchampions/_sidebar.html.erb — Navigation with pending count badgeRoutes Added:
GET /champions → DashboardGET /champions/verifications → Verification queueGET /champions/verifications/:id → Review championPOST /champions/verifications/:id/approve → Approve pending matchPOST /champions/verifications/:id/reject → Reject pending matchGET /champions/verifications/:id/search_alumni → AJAX searchPOST /champions/verifications/:id/link_buid → Manual BUID linkGET /champions/stats → Statistics pageAdmin-only interface for managing geographic districts.
Settings::DistrictsController — Index, edit, update, toggle_highlighted, load_moreRoutes Added:
GET /settings/districts → District listGET /settings/districts/load_more → Paginated load (Turbo Stream)GET /settings/districts/:id/edit → Edit formPATCH /settings/districts/:id → Update districtPATCH /settings/districts/:id/toggle_highlighted → Toggle highlightFiles Created:
app/controllers/champions/base_controller.rbapp/controllers/champions/champions_controller.rbapp/controllers/champions/verifications_controller.rbapp/controllers/champions/stats_controller.rbapp/controllers/settings/districts_controller.rbapp/views/champions/_sidebar.html.erbapp/views/champions/champions/index.html.erbapp/views/champions/verifications/index.html.erbapp/views/champions/verifications/show.html.erbapp/views/champions/verifications/search_alumni.html.erbapp/views/champions/verifications/_search_results.html.erbapp/views/champions/stats/index.html.erbapp/views/layouts/champion_admin.html.erbapp/views/settings/districts/index.html.erbapp/views/settings/districts/edit.html.erbapp/views/settings/districts/_district_rows.html.erbapp/views/settings/districts/_empty_state.html.erbapp/views/settings/districts/load_more.turbo_stream.erbapp/javascript/controllers/champion_admin_sidebar_controller.jstest/controllers/champions/verifications_controller_test.rb (18 tests)test/controllers/settings/districts_controller_test.rb (23 tests)Lessons Learned:
data: { turbo_stream: true } on links (not data: { turbo_frame: "..." }) to ensure proper Accept: text/vnd.turbo-stream.html headerapp/views/settings/settings/index.html.erb — Reordered sectionsapp/views/settings/_sidebar.html.erb — Reordered sectionsdocs/planning/BACKLOG.md for full detailsCreated /tools namespace for staff-accessible utilities, separating them from admin-only Settings:
Tools::BaseController — Base controller with ensure_staff! authorization/tools — Landing page with tool cards/tools/batch_search — Moved from /batch_search/tools/event_converter — Moved from /settings/event_converterWhy: Staff could access Event Converter in Settings but saw admin-only sidebar items (Settings, Users, Data Imports) which they couldn’t access. Now Tools has its own layout with staff-appropriate navigation.
/profile/edit) now see the regular application layout/settings/users/:id/edit) still see the settings layoutdetermine_layout method in Settings::UsersControllerapp/controllers/tools/base_controller.rb — NEWapp/controllers/tools/tools_controller.rb — NEWapp/controllers/tools/batch_search_controller.rb — Moved from batch_search_controller.rbapp/controllers/tools/event_converter_controller.rb — Moved from settings/event_converter_controller.rbapp/views/layouts/tools.html.erb — NEW (tools layout with sidebar)app/views/tools/_sidebar.html.erb — NEWapp/views/tools/tools/index.html.erb — NEW (landing page)app/views/tools/batch_search/* — Moved from batch_search/app/views/tools/event_converter/* — Moved from settings/event_converter/app/controllers/settings/users_controller.rb — Added determine_layout methodapp/views/layouts/_navbar.html.erb — Updated Tools dropdownapp/views/settings/_sidebar.html.erb — Removed Tools sectionapp/views/settings/settings/index.html.erb — Added “Go to Tools” linkconfig/routes.rb — Added tools namespace routes| Old Route | New Route |
|———–|———–|
| /batch_search | /tools/batch_search |
| /settings/event_converter/new | /tools/event_converter/new |
| N/A | /tools (landing page) |
UsersController to Settings::UsersController/settings/users (consistent with other settings pages)users_path → settings_users_path, etc./profile/editApplied consistent modern design across all Settings pages:
ring-1 ring-gray-900/5 shadows/settings) — Redesigned with sectioned card layout/settings/users) — Added avatars, role badges (Admin/Portal Admin/Staff), Google auth indicators*.alumnilookup.comProcfile.dev to use wildcard certificate (fixes SSL errors for both subdomains)app/controllers/settings/users_controller.rb — NEW (moved from app/controllers/users_controller.rb)app/views/settings/users/ — Moved from app/views/users/config/routes.rb — Added resources :users to settings namespaceapp/views/settings/settings/index.html.erb — Modern card designapp/views/settings/users/index.html.erb — Modern table with avatars, badgesapp/views/settings/users/_form.html.erb — Card-based form designapp/views/settings/engagement_activities/index.html.erb — Level-grouped breakdownProcfile.dev — Updated SSL certificate pathsdocs/development/AUTHENTICATION.md — Updated UsersController referencesdocs/development/PERMISSIONS_MATRIX.md — Updated controller namesdocs/development/TESTING_GUIDE.md — Updated path helper examplesdocs/features/AUTH_AND_ROLES_SYSTEM.md — Updated file referencesportal_admin role to User model (between staff and admin)portal_admin? helper method for authorization checksensure_portal_admin! guard in ApplicationControllerstaff → portal_admin → admin| Role | Capabilities |
|——|————-|
| staff | Search, Insights, Tools, Champion Signups (view/edit) |
| portal_admin | Staff + delete/merge signups, Champion Verification (future), Champion Metrics (future) |
| admin | Full access including Settings, User Management, Data Imports |
Reorganized Lookup Portal hamburger menu with clear sections:
ChampionSignupsController: Delete/merge actions now use ensure_portal_admin! (was ensure_admin!)EngagementStatsController: Cache clearing now available to all staff (was admin-only)app/models/user.rb — Added portal_admin role and portal_admin? helperapp/controllers/application_controller.rb — Added ensure_portal_admin! guardapp/controllers/champion_signups_controller.rb — Changed to ensure_portal_admin!app/controllers/engagement_stats_controller.rb — Removed admin restriction on cache clearingapp/views/layouts/_navbar.html.erb — Reorganized menu structuredocs/development/PERMISSIONS_MATRIX.md — Added portal_admin role documentationdocs/features/AUTH_AND_ROLES_SYSTEM.md — Added Phase 7: Portal Admin Roledocs/planning/phases/PHASE-1.md — Updated Sub-Phase 1.5 prerequisites.github/copilot-instructions.md — Added role hierarchy, pause point instructionsCAMP_Email → email_schoolPERS_Email → email_personalCP_Area_Code + CP_Phone_Number (ignores PR_* fields)app/services/csv/banner_importer.rb - Import service with column mappingapp/views/settings/alumni/upload_banner.html.erb - Upload formapp/views/settings/alumni/import_banner_preview.html.erb - Preview pagetest/services/csv/banner_importer_test.rb - 24 service teststest/controllers/settings/alumni_controller_banner_test.rb - 9 controller testsGET /settings/upload_banner - Upload formPOST /settings/import_banner_preview - Preview importPOST /settings/import_banner_commit - Commit importpending_buid for staff review, do NOT auto-link or auto-verifypending_buid column to cp_champions - stores BUID selected by champion awaiting staff verificationpending_buid_match_type column - stores how match was found (‘email’ or ‘name’)pending_buid for finding champions needing verificationpending_alumni method to Cp::Champion - returns Alumni record for pending_buidpending_alumni method testsprofile_completion_percentage - returns 0-100 weighted scoreprofile_completion_details - returns hash with status per areaincomplete_profile_areas - returns array of missing areas sorted by priorityhas_champion_role?, has_work_info?, has_affinities?, has_bio?dismissable_controller.js Stimulus controllerlegal_first_name column to cp_champions tablefirst_namenormalize_names callback clears legal_first_name if matches first_namelegal_first_name and college_last_name to improve matchingconfirm_education if match now foundfind_potential_alumni_matches now uses ALL name fields:
first_name OR legal_first_name matched against alumni first_name/pref_namelast_name OR college_last_name matched against alumni last_name/maiden_nameCp::SyncChampionToAlumni service for syncing data to Alumni recordslegal_first_name present → alumni.first_name=legal, alumni.pref_name=firstCrmDataChange for Affinaquest exportapp/javascript/controllers/name_checkbox_controller.js - Stimulus controller for checkbox toggleapp/views/cp/profile_wizard/_step_help_find_you.html.erb - Help find you step viewapp/services/cp/sync_champion_to_alumni.rb - Champion-to-Alumni sync servicedocs/features/champion_portal/NAME_FIELDS_AND_EXPORT_MAPPING.md - Field mapping documentationCrmDataChange#cp_champion association missing class_name: "Cp::Champion"location_confirm_controller.js Stimulus controller</div> tags causing broken navigationfilterAffinities() now accepts optional event parameter:sparkles icon case for Champion Role stepwizard_completed_at.blank?@roles = champion_roles to load_step_data (was returning nil)before_save :normalize_names callback to Champion modelfirst_name ↔ pref_first_name when one is blankneeds_profile_wizard? instead of zip_code.blank?wizard_completed_at timestamp columncp_affinity_suggestions table — Stores champion-submitted suggestions
Cp::AffinitySuggestion model — Validates and stores suggestions/profile/wizard/suggest-affinitylocation_contact → locationprofessional → professionwizard_completed_at valuesnarrative_for generates personalized role descriptionscalculate_primary_role finds most frequent matchgenerate_results returns primary role, all roles with percentages, narrativechampion_role step as 2nd step in wizard (after basic_info)champion_role section to profile edit sidebarsession[:quiz_referrer])quiz_return_path helper determines correct return destinationnext_incomplete_wizard_step includes champion_role after basic_infoat_least_one_name_present (pref_first_name OR first_name)display_first_name method falls back from pref_first_name to first_nametoggle-legal-name handles expand/collapse with animated icontoggle-role-selection for expandable role optionsvalidates :first_name to validates :pref_first_namechampion_role (6 steps instead of 5)quiz, save_quiz_answer, quiz_results, select_quiz_roleGET /profile/wizard/quiz/:question for quiz questionsPOST /profile/wizard/quiz/:question for answer submissionGET /profile/wizard/quiz-results for results displayPOST /profile/wizard/select-role for role selectionprofile/wizard/:step to prevent route conflict_step_champion_role.html.erb, quiz.html.erb, quiz_results.html.erbcp_affinities join tableSECTIONS constant defines available sectionssection_params method scopes permitted params per sectionbio to professional section params (was missing)GET /profile/edit(/:section) for section-based navigationAffinaquestScanJob running in background workerAffinaquestApplyJob applies changes in backgroundManifestStore service stores manifests in Redis with 1-hour TTLpossibly_stuck? method on batch modelcrm_data_changes
Csv::AffinaquestContactImporter.scan(file) - Read-only analysis, returns manifestCsv::AffinaquestContactImporter.apply_manifest(manifest, imported_by:) - Targeted updatesscanning, scanned added to workflow
POST /settings/affinaquest/upload - Starts scan jobGET /settings/affinaquest/batches/:id/scanning - Progress pageGET /settings/affinaquest/batches/:id/preview - Shows manifestPOST /settings/affinaquest/batches/:id/commit - Starts apply jobCrmDataChange.preload_protections(buids) for bulk loadingmanifest_path column to affinaquest_import_batches (stores Redis key)crm_data_changes table with “staff_edit” sourceprotecting, for_buid, for_fieldCrmDataChange.field_protected? method for import checksemail_school field
SCHOOL_EMAIL_DOMAINS constant: bruins.belmont.edu, belmont.eduMaiden Name Protection - Importer preserves existing last names when they differ
/settings/affinaquest/crm_changes
bin/rake crm:create_restored_protections - Create protection records for manually restored databin/rake crm:fix_school_emails - Fix school emails placed in wrong fieldemail_school, email_personal, email_business, email_other, zip, affinaquest_updated_at, affinaquest_synced_ataffinaquest_import_batches, affinaquest_import_conflictscrm_data_changes, crm_data_export_batchesAffinaquestImportBatch - Track import history with lifecycle methodsAffinaquestImportConflict - Handle ID mismatches with resolve/dismiss workflowCrmDataChange - Unified change tracking for CRM export (Affinaquest conflicts, Champion Portal updates, manual edits)CrmDataExportBatch - Export batch management with convenience class methodsfilter_by_any_email, filter_by_zip, with_degrees, without_degrees scopes; has_degree?, zip_district, zip_region, all_emails methodsCsv::AffinaquestContactImporter - Main import orchestratorCrmDataChange.log_affinaquest_conflictSettings::AffinaquestController with 8 actions (index, preview, commit, conflicts, export, resolve, batches, show_batch)/settings/affinaquest/* for full import workflowGET /api/districts/autocomplete?query=...district_autocomplete_controller.jsfilter_by_any_email scope checks email, email_school, email_personal, email_business, email_otherAlumniLookupService and AlumniMatcher updated for multi-email matchingdocs/features/AFFINAQUEST_IMPORT.md - Complete feature documentationAlumniLookupService (BUID, BQID, email, name)first_name = "John", last_name = "Smith +1"rsvp_email / system_email / email_differs columnsrsvp_phone / system_phone / phone_differs columnsgenerate_name_variations() at search-timefind_name_candidates() method for ambiguous match resolutionfilter_by_bqids scope on Alumni model
filter_by_buids for batch BQID lookupsapp/services/csv/event_rsvp_converter.rb — Core conversion logicapp/services/alumni_lookup_service.rb — Centralized alumni matching serviceapp/controllers/settings/event_converter_controller.rb — Upload, preview, download, resolve actionsapp/views/settings/event_converter/new.html.erb — Upload formapp/views/settings/event_converter/preview.html.erb — Preview with resolution UIdocs/planning/event-checkin-integration/04-event-rsvp-converter.md — Feature documentationdocs/development/AGENTS.mddocs/development/TODO_BUGS.mdtest/services/csv/event_rsvp_converter_test.rb — Unit teststest/services/alumni_lookup_service_test.rb — Service teststest/controllers/settings/event_converter_controller_test.rb — Controller teststest/fixtures/files/givecampus_rsvp_sample.csv — Sample fixture data/auth path prefix for multi-model Devise supportdevise_scope blocksCp::ProfileController for profile view/edit/profile/complete)Cp::OmniauthCallbacksController — Google OAuth callback handlingCp::ProfileController — profile show/edit/complete actionstest/controllers/cp/omniauth_callbacks_controller_test.rbtest/controllers/cp/profile_controller_test.rbCp:: namespace for full controlCp::ChampionMailer for Champion Portal emails using champions subdomaindev.alumnichampions.com:3000/public/alumni-champions.svgdropdown_controller.js - Desktop profile dropdown with click-outside closemobile_menu_controller.js - Mobile hamburger menu toggleCp::BaseController - feature flag checking, layout setupCp::RegistrationsController - progressive signup flowCp::SessionsController - login/logoutCp::ConfirmationsController - email confirm + password settingCp::PasswordsController - forgot/reset passwordCp::DashboardController - authenticated dashboardtest/controllers/cp/registrations_controller_test.rb (4 tests)test/controllers/cp/sessions_controller_test.rb (5 tests)test/controllers/cp/dashboard_controller_test.rb (4 tests)test/controllers/cp/confirmations_controller_test.rb (8 tests)test/controllers/cp/passwords_controller_test.rb (8 tests)test/models/cp/champion_test.rb (14 tests)test/mailers/cp/champion_mailer_test.rb (10 tests)regions - 7 geographic regionsdistricts - 820 metro/micro areaszip_codes - 39,305 ZIP codes with city/statecp_champions - Champion Portal user accountscp_profile_changes - CRM export changelograils champion_portal:seed_geographic_dataCp::Champion with Devise authentication (email auth only for now)devise_mailer methodRegion, District, ZipCode models with associationsAlumni → Cp::Champion association via BUIDplanning/event-checkin-integration/)alumni:cleanup_duplicate_buids to handle duplicate BUID records before adding unique indexAddressed TODO items: fixed bugs, added validation, and expanded test coverage.
app/views/alumni_affinities/_form.html.erbactivity_code against registered EngagementType codesreset_valid_activity_codes_cache! methodapp/models/engagement_activity.rbtest/controllers/engagement_activities_controller_test.rb - 12 tests
test/controllers/engagement_stats_controller_test.rb - 21 tests
test/controllers/batch_search_controller_test.rb - 21 tests
test/models/engagement_activity_test.rb - 13 tests
@alum/@alumni usage is consistent across all controllersComplete refactoring of engagement stats to use dedicated services, fixing duplicate counting bug.
EngagementScoreCalculator exactly
OverviewService - Goal #1, Goal #2, Goal #3-5 metricsDemographicsService - Year/college/major breakdowns (uses COUNT(DISTINCT buid))BreakdownService - Activity breakdown by role/levelMatrixService - Quadrant analysis with engagement/giving axesAnalyticsService - Charts, score distributions, monthly activity stacksActivityPairsService - Activity combination analysistest/services/engagement_stats/breakdown_service_test.rbtest/services/engagement_stats/matrix_service_test.rbtest/services/engagement_stats/analytics_service_test.rbtest/services/engagement_stats/activity_pairs_service_test.rbtest/services/engagement_stats/scoring_integration_test.rb - Ensures scoring consistencyAnalyticsService with monthly_activity_stacks for stacked bar chart dataBaseService#with_caching for consistent 1-4 hour cache expirationEngagementStats::BaseServicecapped_level_to_points_sql now applies activity-type caps via SQL subqueriesbuid column in joined queriesEngagementType.all.index_by (not .index_by directly)feature_enabled? helper available in controllers and viewschampion_portal flag ready for Champion Portal developmentdocs/development/FEATURE_FLAGS.md for usagelib/tasks/populate_champion_roles.rbUpgraded Ruby for 15-25% performance improvement.
csv gem proactively (becomes non-default in Ruby 3.4)Full CI/CD pipeline implemented with staging environment.
.github/workflows/ci.yml)
alumni-lookup-staging)
deploy-staging.yml - Triggers after CI passes on maindeploy-production.yml - Triggers on version tags (v*)docs/operations/DEPLOY_GUIDE.md - Step-by-step deploy instructionsdocs/operations/ENV_SETUP_IMPLEMENTATION_CHECKLIST.md - Full setup checklistHEAD:main for tag-based production deploysThe following versions were developed before CI/CD was established. They represent feature milestones but were not formally tagged releases.
All 6 phases completed. Foundation ready for Champion Portal and Event Check-in.
can_* boolean flags with admin bypasscan_event_checkin, can_event_manage flagsomniauth-google-oauth2 and omniauth-rails_csrf_protection gemsgoogle_uid and google_linked_at columns to users tableUsers::OmniauthCallbacksController for Google OAuth2 callbackgoogle_connected?, unlink_google!, and from_google_oauth methods to User modelGOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars on Herokusame_site: :lax for OAuth compatibilityMailgunDeliveryMethod class)mailer_sender from placeholder to noreply@mail.alumnichampions.comdefault_url_options (was polluting other mailers)alumnilookup.com for Devise emails, alumnichampions.com for Champion emailsnew.html.erb, edit.html.erb) with Tailwindensure_staff! method to ApplicationController for staff-accessible featuresensure_staff! or mixed staff/admin accessstaff_access_test.rb with 18 testsrole column with values: staff (default) and adminstaff? helper method (returns true for both staff and admin)admin? to check role enum OR legacy admin booleanadmin? method to User model that uses the admin booleanensure_admin! method to ApplicationController as standardized authorization callbackensure_admin! instead of local methodsaccess_level column from users tablefeatures/AUTH_AND_ROLES_SYSTEM.md — Complete authentication & role system (moved from planning/)planning/event-checkin-integration/00-overview.md — Problem, goals, recommendationplanning/event-checkin-integration/01-pros-cons-and-questions.md — Pros/cons analysis, pre-integration questionsplanning/event-checkin-integration/02-phased-integration-plan.md — Phased development roadmap with Contact/Event/Registrant modelsplanning/event-checkin-integration/03-champion-portal-interaction.md — Champion Portal alignment and sequencingPERMISSIONS_MATRIX.md
See development/TODO_BUGS.md for tracked bugs and issues.
PRODUCT_OVERVIEW.md)affinity_name helper crash on nil inputaffinity_code is nilUsersController#destroy — @user not set before actionalumnis.yml → alumni.yml)ARCHITECTURE.md — Two-domain architecture documentationAUTHENTICATION.md — Devise setup and authorization patternsAPI.md — Internal API endpoint documentationPERMISSIONS_MATRIX.md — Role-based access blueprintalumnichampions.com
alumnilookup.comunaccent extension for name searchesThis project uses semantic versioning:
| Version | Date | Milestone |
|---|---|---|
| 1.0.0-alpha.0 | Early 2025 | Initial development |
| 1.0.0-alpha.1 | Aug-Oct 2025 | Champion Signup system |
| 1.0.0-alpha.2 | Nov 2025 | Documentation & prep |
| 1.0.0-alpha.3 | Nov-Dec 2025 | Auth & Roles system |
| 1.0.1 | Dec 2, 2025 | CI/CD Pipeline (first tagged release) |
| 1.0.2 | Dec 2, 2025 | Ruby 3.3.8 + YJIT |
| 1.0.3 | Dec 3, 2025 | Service refactoring + Feature flags |