Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/alumni-network/source/README.md. Prefer quoting/paraphrasing over inventing new language.
Status: ✅ Complete (20.1, 20.2, 20.3 + content-surface follow-up all shipped) — pending release tag
Scope: belmontalum.com portal (Cp::) ONLY. Nothing in this phase touches the alumnichampions.com public signup flow (Phase 19).
Related: FEATURE_FLAGS.md (existing env-var pattern), Cp::DashboardVisibility
The Champion Portal has grown too large to get approved as one launch. We need to ship a stripped-down, approvable core and re-introduce the heavier features one at a time as each gets sign-off — without deleting or rebuilding anything.
Staff-toggleable feature flags that hide five portal features from users while leaving all data, models, background jobs, and staff-side management fully intact. Flip a flag on when the feature is approved; flip it off instantly if needed.
| Flag key | Feature | Default (prod) |
|———-|———|—————-|
| news | Community News (Phase 1.10 + Phase 14 submissions) | OFF |
| photos | Photo Albums (Phase 5.5) | OFF |
| events | Events (Phase 1.11 + Phase 14 submissions) | OFF |
| champion_role | Champion Role microsite + role cards + role_ideas (Phase 11/13) | OFF |
| your_impact | Your Impact dashboard card (Phase 13) | OFF |
Auth / Profile / Directory / Communities (info + members + discussions) / Connections + Messaging / Careers. A coherent “alumni directory + communities” MVP that can be approved on its own.
| Question | Decision |
|---|---|
| Mechanism | DB-backed admin toggle UI. cp_feature_flags table + a Champion Admin page where portal_admins flip switches live — no deploy/dyno restart. Env var retained as an emergency kill-switch override. |
| Staff side while dark | Keep staff side fully live. Flags hide ONLY champion-facing surfaces. Staff keep creating/editing news, events, albums, role ideas in Champion Admin so content is staged and ready the moment a flag flips on. |
| Direct-URL when OFF | Redirect to dashboard (cp_home_path), no error. A logged-in user who bookmarked a gated page is bounced home. |
| Champion Role reach | Portal (belmontalum.com) ONLY. Nothing touches the just-launched alumnichampions.com Phase 19 signup flow. The public interest-spectrum quiz is entirely out of scope. |
| Profile-wizard role step | Skip entirely when champion_role is OFF (wizard flows straight past it). Users never encounter roles anywhere in the portal. |
| Your Impact ↔ Role | Independent flags. No hard coupling — five fully independent switches. (Operational note: don’t run your_impact ON with champion_role OFF, since Impact stats are role-framed.) |
Each environment has its own database, so a single enabled boolean per flag is naturally per-environment. No per-env columns needed.
Resolution order (first match wins) for cp_feature_enabled?(:news):
FEATURE_NEWS is set ("true"/"false"), it wins. Emergency kill-switch, matches existing FEATURE_CHAMPION_PORTAL pattern.Cp::FeatureFlag row’s enabled column.false (fail-closed).Environment defaults after deploy (implemented: rows are created with enabled: !Rails.env.production?, so existing dev/staging behavior is unchanged and production is the only env that starts dark):
| Env | How flags arrive | Expected state |
|—–|——————|—————-|
| Development | Migration data-insert (or db/seeds.rb for schema:load), enabled: true | All ON (local dev unaffected) |
| Test | Fixtures test/fixtures/cp/feature_flags.yml, all enabled: true | All ON (existing suite unaffected; individual tests disable as needed) |
| Staging | Migration inserts rows enabled: true; staff flip OFF to preview the stripped launch | Toggle per feature during QA |
| Production | Migration inserts rows enabled: false | All OFF at launch; flip ON per approval |
Flag hides the user-facing column only. Everything in Staff/admin and Data preserved stays live.
| Feature | User-facing surfaces to gate | Staff/admin (STAYS LIVE) | Data preserved | Flow amendment when OFF |
|---|---|---|---|---|
| News | Community show “Latest News” block; News index/show; toggle_like; news submit flow; My Submissions (news items); dashboard What’s-New / activity feed news items | champions/news_posts CRUD; Phase 14 submission review |
cp_news_posts, submissions, likes |
Community left column reflows to Discussions-only; submission CTA hidden; activity feed filters out news |
| Photos | Community “Photo Albums” carousel; album index/show; event→album “create album” link | champions/photo_albums + photos CRUD |
albums, cp_photos, ActiveStorage blobs |
Community carousel removed; event create-album hidden |
| Events | Dashboard sidebar :events (desktop + mobile “events first” block); community “Upcoming Events” + RSVP click; event index/show; event submit flow; My Submissions (event items) |
champions/events CRUD; submission review |
cp_events, RSVPs, submissions |
Community right column reflows; dashboard drops events sidebar; mobile events block gone |
| Champion Role | Roles microsite (/roles, /roles/:role, /champion-info); role select/remove; role_idea_clicks; dashboard role_ideas main section + role card; champion_microsite_nav; profile-wizard role quiz step (skipped); has_champion_role? branches |
champions/role_ideas CRUD; click analytics |
roles on cp_champions, quiz answers, role_idea content |
Wizard skips role step; microsite nav hidden; dashboard drops role_ideas + role card |
| Your Impact | Dashboard sidebar :your_impact card (no standalone route) |
— | derived stats (no dedicated table) | Sidebar drops the card |
# cp_feature_flags
t.string :key, null: false # "news", "photos", "events", "champion_role", "your_impact"
t.boolean :enabled, null: false, default: false
t.string :name # display label, e.g. "Community News"
t.text :description # admin-facing explanation of what it gates
t.references :last_toggled_by, foreign_key: { to_table: :users }, null: true
t.timestamps
# add_index :cp_feature_flags, :key, unique: true
# app/models/cp/feature_flag.rb
module Cp
class FeatureFlag < ApplicationRecord
KEYS = %w[news photos events champion_role your_impact].freeze
validates :key, presence: true, uniqueness: true, inclusion: { in: KEYS }
# Resolution: ENV override → DB → false. Cached; busted on write.
def self.enabled?(key)
env = ENV["FEATURE_#{key.to_s.upcase}"]
return env == "true" if env.present?
cached_map.fetch(key.to_s, false)
end
def self.cached_map
Rails.cache.fetch("cp_feature_flags/map", expires_in: 1.hour) do
where(enabled: true).pluck(:key).index_with { true }
end
end
after_commit { Rails.cache.delete("cp_feature_flags/map") }
end
end
Caching: flags are read on nearly every portal request/view — the
cached_map(a small hash of enabled keys) is cached and invalidated on write. Watch Heroku memory (see/performance): cache the compact map, not full records.
Cp::FeatureFlag.enabled?(:news) — canonical check (model-level).cp_feature_enabled?(:news) — helper_method on Cp::BaseController, available in all portal controllers + views. Thin wrapper over the model.config.x.features / feature_enabled?(:champion_portal) mechanism untouched — that governs portal-wide access; these five are content flags.before_action :require_feature!, only: [...] (or class-level) in Cp::NewsController, EventsController, PhotoAlbumsController, RolesController, NewsSubmissionsController, EventSubmissionsController. Off → redirect_to cp_home_path.Cp::DashboardVisibility#show? also consults the flag map: events → :events, your_impact → :your_impact, role_ideas → :champion_role. Flag OFF wins regardless of journey stage. (Single choke point covers most dashboard rendering.)cp_feature_enabled?. Reflow community columns gracefully when a section is hidden (no empty gaps).champion_role OFF (step sequencing in the wizard controller/service).Champions::FeatureFlagsController (index + update) at /champions/feature_flags, portal_admin-guarded, listed in Champion Admin nav (Settings area). Toggle switches with the flag name + description; stamp last_toggled_by.cp_feature_flags (seed 5 rows, enabled: !production).Cp::FeatureFlag model (resolution order, caching, invalidation) + KEYS.cp_feature_enabled? + require_feature! helpers on Cp::BaseController.Champions::FeatureFlagsController (index/update) + view + Champion Admin nav entry (portal_admin guard).db/seeds.rb idempotent block; test/fixtures/cp/feature_flags.yml all enabled: true.What Was Implemented (20.1):
20260720120000_create_cp_feature_flags — table (key unique, enabled, name, description, last_toggled_by → users) + 5 rows via a local throwaway class, enabled: !Rails.env.production?.Cp::FeatureFlag (app/models/cp/feature_flag.rb) — KEYS (closed set of 5), enabled?(key) with resolution ENV (FEATURE_<KEY>) → cached DB map → false. enabled_map caches the compact enabled-keys hash (memory-light); after_commit busts it.Cp::BaseController — cp_feature_enabled?(feature) (helper_method, available in portal controllers + views) and require_feature!(feature) (before_action guard; OFF → redirect_to cp_home_path). Used starting in 20.2/20.3.Champions::FeatureFlagsController (index, update) — lists flags in KEYS order; update casts the boolean, stamps last_toggled_by = current_user, redirects with an ON/OFF notice. portal_admin-guarded via Champions::BaseController.champions/feature_flags/index.html.erb — per-flag rows (name, key, On/Off badge, description, last-changed-by) with a button_to PATCH toggle; a reassurance note that OFF only hides from alumni.champions/_sidebar.html.erb with an “N on” count badge.find_or_create_by! block (covers schema:load setups; enabled: !production on first create only).test/models/cp/feature_flag_test.rb (9) + test/controllers/champions/feature_flags_controller_test.rb (6). Full suite 4634 runs / 0 failures / 0 errors / 3 skips.Spec deviation (20.1): §3 originally drafted staging rows as enabled: false. Implemented as enabled: !Rails.env.production? instead, so existing dev/staging behavior is untouched and only production starts dark — fewer surprise regressions. §3 table updated to match.
require_feature! + redirect on News/Photos/Events + their submission controllers.DashboardVisibility events integration; community-show reflow (News/Photos/Events sections); activity-feed / What’s-New filtering.What Was Implemented (20.2):
before_action -> { require_feature!(:key) } on Cp::NewsController + Cp::NewsSubmissionsController (:news), Cp::EventsController + Cp::EventSubmissionsController (:events), Cp::PhotoAlbumsController (:photos). OFF → redirect_to cp_home_path. Applied to EventsController#show too (the public event page) so it’s gated for signed-out visitors as well.Cp::DashboardVisibility — new SECTION_FEATURE_FLAGS map (events → :events) and feature_flagged_off?; show? returns false when a section’s flag is off, regardless of journey stage. Single choke point covering the desktop sidebar loop (visible_sidebar_sections), the mobile “events first” block, and load_upcoming_events.Cp::HomeController — load_whats_new_data contributes 0 for news/events/photos when their flag is off; load_activity_feed rejects news?/photo_album? items when off (discussions ungated).cp/communities/show.html.erb) — News section, Photo Albums carousel, and Upcoming Events card each wrapped in cp_feature_enabled?. Columns reflow naturally (left keeps Discussions, right keeps Members/CLs) — no empty gaps.test/controllers/cp/feature_flag_gating_test.rb (11: controller redirects both states + community reflow + all-off dashboard smoke) and 2 new DashboardVisibility cases. Full suite 4648 runs / 0 failures / 0 errors / 3 skips.Deferred (noted, not built): The weekly digest mailer (cp/notification_mailer/weekly_digest) still lists news/events/photos content. Not gated in 20.2 — digests are a separate notifications concern and, with content flags off at launch, there’s little new content to surface; any links resolve to the dashboard via the controller guards rather than erroring. Follow-up candidate → BACKLOG.
role_ideas dashboard section via DashboardVisibility (champion_role); champion_microsite_nav hidden; has_champion_role?-dependent UI guarded.your_impact, independent).What Was Implemented (20.3):
:champion_role) — class-level require_feature! on Cp::RolesController, Cp::RoleIdeaClicksController, Cp::ChampionInfoController; action-scoped on Cp::ProfileWizardController (quiz, save_quiz_answer, quiz_results, select_quiz_role) and Cp::HomeController#refresh_role_ideas. OFF → redirect to dashboard.Cp::DashboardVisibility — SECTION_FEATURE_FLAGS extended: role_ideas → :champion_role, champion_nudge → :champion_role, your_impact → :your_impact. Covers the role card data load, the role-ideas main section, the champion nudge, and the Your Impact sidebar in one place.Cp::ChampionRolesHelper#champion_role_badge returns nil when champion_role is off, hiding role badges portal-wide (directory cards, discussions, boards, hero, profile) from a single edit._role_card early-returns when off (covers all 4 stage templates); _hero role text, profile/show Champion Role section, and the layout champion-nudge bar each wrapped in cp_feature_enabled?(:champion_role).your_impact via DashboardVisibility; verified independent of champion_role.feature_flag_gating_test.rb (microsite/detail/champion-info/quiz redirects, badge+section hidden on profile, all-five-off dashboard smoke) and dashboard_visibility_test.rb (role_ideas/champion_nudge gating, your_impact independence). Full suite 4657 runs / 0 failures / 0 errors.Spec deviation (20.3): The plan said “skip the profile-wizard role step.” There is no champion_role step in the wizard’s STEPS array — the stale doc comment in ProfileWizardController (lines 6-13) implied one, but the role quiz is a set of dedicated actions (quiz/save_quiz_answer/quiz_results/select_quiz_role) reached from the role card / nudge / roles microsite, not a linear step. So there was nothing to skip; instead those quiz actions are gated directly. Also added beyond the literal spec: gating the champion_role_badge helper so role badges disappear everywhere at once (cleaner than editing ~10 views), and gating the champion_nudge (it is entirely role-promotion copy → “Find Your Role”).
A “search for news/events/champion role” sweep surfaced non-functional references the controller/dashboard gating didn’t reach:
config/faq.yml + Cp::HelpController#filter_by_features) — questions/categories now carry an optional feature:/features: tag; the controller drops questions whose feature is off and any category left empty. Tagged: “Champion Roles” category (champion_role), the community-news question (news), the three event questions + “career events” (events). “News & Events” disappears only when both are off.events is off (feature: marker on the type + a reject pass before empty-group pruning).cp_feature_enabled? moved from Cp::BaseController → ApplicationController because the champions layout is also rendered by ApplicationController subclasses (Help, Policies) that were calling it. require_feature! stays on Cp::BaseController.cp/home/sidebar/_connections.html.erb was last touched in an earlier connections-education commit, not by Phase 20. It’s the intended first-3-connections progress widget.cp_home_path).DashboardVisibility: flag OFF hides section even at a journey stage that would show it.champion_role OFF (role step absent).bin/test — 0 failures / 0 errors before commit.event_rsvp, news like) simply won’t fire while a feature is dark — acceptable.last_toggled_by + updated_at; a dedicated activity event is not required).Create docs/planning/qa/PHASE_20_LAUNCH_GUIDE.md during implementation: the migration, the seed/fixture step, per-environment default states, and the “flip to launch” runbook (which flags ON for the basics launch = none; re-enable order as approvals land).
cp_feature_flags migration + 5 seeded rowsCp::FeatureFlag model (resolution, cache, invalidation)cp_feature_enabled? helper on Cp::BaseControllerChampions::FeatureFlagsController + view + nav entrydocs/development/FEATURE_FLAGS.md (add the 5 DB-backed flags + admin-UI section)app/controllers/champions/roadmap_controller.rbPHASE_20_LAUNCH_GUIDE.md