Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/alumni-network/source/README.md. Prefer quoting/paraphrasing over inventing new language. External-facing copy leads with “alumni” / “Bruins” — no gatekeeping.
Status: ✅ Complete (shipped 2026-07-27 — all five sub-phases, plus the 21.6 post-review polish; the four opportunities are still to be authored through the CMS on staging, see §6). 21.7 extends the phase — see the sub-phase spec below.
Scope: Entirely within the alumnichampions.com signup ecosystem (Public::, Phase 19). Public shareable forms + a returning-signup surface on the profile hub. Does not touch belmontalum.com / Cp:: — see §7 for the convergence guardrails that make this a deliberate boundary, not an oversight.
Related: CHAMPION_SIGNUP_SYSTEM.md, Phase 11 Role Ideas, config/welcome_packs.yml (YAML-config precedent).
Launch guide: PHASE_21_LAUNCH_GUIDE.md — migrations, QA focus areas, rollback.
Sub-phase specs: 21.7 — Checkbox Fields, Link-Only Visibility, Response Notifications (2026-08-05)
Alumni are willing to do specific things for Belmont — represent the school at an admissions fair, host a game watch party, speak to prospective students, host a send-off party. Today there’s no owned, on-brand way to (a) invite them into a concrete opportunity, (b) capture their response into our staff tools, or (c) turn an interested-but-anonymous person into a Champion. Google Forms captures answers but throws away identity, conversion, and CRM linkage.
Opportunities — a small set of config-defined, structured forms. Each is a concrete thing an alum can sign up to do. Forms are reachable as public shareable links (anonymous-capable) and on the signup profile hub (pre-filled for a returning signup who arrives via their emailed profile link or permanent access code). Submissions become OpportunityResponse records that staff can review, get notified about, and export. Anonymous submitters are invited to “make it official” and complete the existing Champion signup — pre-filled from what they just typed.
LANGUAGE_STYLE_GUIDE.md.| Question | Decision |
|---|---|
config/opportunities.yml, mirroring config/welcome_packs.yml. Form structure + all copy (intro, thank-you, next-steps) live in config; only responses are in the DB. Copy tweaks = config change + deploy (acceptable — few opportunities, changed rarely). |
|
| Question flexibility | Typed fields, small fixed set: contact_block, short_text, long_text (essay), select, date. Covers all four examples without a builder. |
| Anonymous submitter data | Standalone OpportunityResponse is the primary record. No ChampionSignup is created on submit. Conversion happens only if they opt in on the thank-you page. |
| Fulfillment workflow | Lead capture + notify + export. No per-response status workflow in MVP. Staff follow up offline. |
| vs. Role Ideas | Separate concept, distinct surface. No coupling to Phase 11. |
| Entry points | Public deep links (alumnichampions.com/opportunities/:slug, shareable) + the signup profile hub (GET /sign-up/:id, for a returning known signup). No belmontalum.com / portal surface (deferred — see §7). No public index page — staff drive traffic to specific opportunities. |
| Where it lives | The alumnichampions.com signup experience — a returning signup’s “login” is possession of their emailed profile token or permanent access_code, not a portal account. Corrected from the initial draft, which placed the authenticated surface in the Cp:: portal. |
| Repeat submissions | Allowed. No unique constraint — someone may host multiple watch parties. Each submission is its own response. |
Phase 19.10–19.12 shipped after the original interview and changed two things this phase depends on (Signup Admin sidebar IA; lead location now displays city, not metro district). This addendum resolves what that surfaced.
| Question | Decision |
|---|---|
| Consent on anonymous submissions | Mirror 19.11. opportunity_responses gains privacy_policy_accepted_at + privacy_policy_version, using the same consent checkbox/copy pattern as the signup form. No sms_opt_in — phone stays a plain optional field; keep the form short. |
| Anonymous submit whose email matches an existing signup | Auto-link server-side, never grant access on-screen. On save, ChampionSignup.find_active_by_email stamps champion_signup_id. The thank-you page shows identical copy to everyone and grants no hub access. If they want to manage their record they get a link emailed to the address on file, via the existing 19.7 request_link flow. Rationale: typing someone else’s email into a public opportunity form must never become a backdoor login, and identical copy also closes the email-enumeration leak a “Welcome back” would open. |
| Location storage | zip + derived city/state + district_id. All three derived from ZipCode.lookup on save, matching what champion_signups now stores. Staff list displays city/state (consistent with commit 55dd1bf3); district is retained for filtering and rollups. |
| Sub-phase order | Reordered: 21.1 → 21.4 → 21.2 → 21.3. Model, then staff queue/notify/export, then the public form. Nothing anonymous can be captured before staff can see it. Same total scope. |
| Admin IA placement | New “Opportunities” sidebar section in champion_signups/_sidebar.html.erb, between Signups and Analytics, with a Responses item carrying a count badge (matching the All Signups / Duplicates badge pattern). Opportunities are a distinct concept, not a signup view. |
| Config copy format | Markdown, not raw HTML. Keys rename to intro_md / thankyou_md / next_steps_md, rendered through Redcarpet with the same sanitization as Cp::WelcomeContentGenerator. One rendering path; staff-editable without hand-written HTML reaching the page. |
| Spam protection | Honeypot + per-IP rate limit. SignupHoneypot reuse plus a rack-attack throttle on POST /opportunities/:slug, mirroring the existing champion-signups/ip rule (limit 5, period 30.minutes). Legitimate repeat submissions are days apart, so the limit never bites real users. No notification batching in MVP. |
| Spec correction | §4 (21.1) previously listed an index on champion_id. No such column exists in MVP — it is deferred with the portal surface (§7). Indexes are opportunity_slug, district_id, champion_signup_id. |
contact_block = first_name, last_name, email, zip_code required; phone optional — matching signup step 1 (who_you_are).
select — requires an options array; submitted value must be one of them (server-side validated, not just a <select>).date — coerced via the existing Csv::DateParser conventions; rejects unparseable input, no min/max constraints in MVP.short_text — 255-char cap; long_text — text column, no cap.Supersedes the “Opportunity definition storage” row in §2. Opportunity definitions move from config/opportunities.yml into a DB-backed opportunities table with a Champion Admin CMS.
The original YAML decision assumed few opportunities changing rarely, so a deploy-per-edit was acceptable. Re-examined against the goal of “a clear pathway to add more,” that assumption doesn’t hold: every new opportunity would require a developer, which is most of what the pathway needs to deliver. The champions/ namespace already has ~20 CRUD controllers to model on — Champions::SeededQuestionsController is a near-exact structural match (index/show/new/create/edit/update/destroy + preview + activate/deactivate), with a five-file view set.
§1’s “What this is NOT” still holds. The questions editor is deliberately constrained:
contact_block, short_text, long_text, select, date). A new type is still a code change.config/opportunities.yml and the Opportunity PORO/YAML loaderrake opportunities:validate_config task (validation moves to model validations)opportunities table + Opportunity ActiveRecord model (was a PORO)Champions::OpportunitiesController + five-view set, modeled on seeded_questionsfield_rows_controller.js Stimulus controller (repeatable rows). Note: the codebase has no existing repeatable-row/nested-form pattern across its 93 Stimulus controllers, and only one accepts_nested_attributes_for — this is the single genuinely new piece of UI in the phase.Net cost: roughly +1 to 1.5 days over the YAML approach, nearly all of it the row editor. OpportunityResponse is unchanged — it already keys answers jsonb by field key, which works identically whether the key came from YAML or a DB row.
| Rule | Why |
|---|---|
Field key is auto-generated from the label at creation and frozen forever |
answers jsonb is keyed by it. A renamed key orphans every prior answer. Staff edit the label; the key never moves. |
Fields are soft-deleted (active: false on the row), never removed |
Removing a field would strand its answers. Inactive fields stop rendering publicly but stay readable in the staff view and CSV export. |
| Slug is immutable once the opportunity has been activated or has responses | It’s in the public shareable URL. Editing it breaks every link already sent out. |
| UI warns when editing an opportunity that has responses | Staff need to see that questions are effectively append-only at that point. |
| Markdown fields are staff-authored input rendered on a public page | Render through Redcarpet with the same sanitization as Cp::WelcomeContentGenerator — no raw HTML passthrough. |
A Duplicate member action on the CMS — the highest-leverage shortcut in the whole editor, since most new opportunities are structural variations of an existing one (contact block + a few questions). Duplicating and editing skips rebuilding the field rows from scratch.
POST /champions/opportunities/:id/duplicate deep-copies the record and redirects to the new one’s edit page. Mirrors the existing activate / deactivate member-action shape on Champions::SeededQuestionsController.
| On the copy | Value |
|---|---|
title |
"#{original.title} (Copy)" — staff rename immediately |
slug |
auto-generated unique slug; editable, because the copy has no responses yet |
active |
always false — a duplicate is never born public |
| markdown copy | copied verbatim (intro_md, thankyou_md, next_steps_md) |
fields |
copied in full, including inactive rows’ labels/types — but keys regenerate, since this is a distinct opportunity with no answers to preserve |
| responses | none — never copied |
The copy starts life in the fully-editable state (no responses ⇒ slug unlocked, fields freely editable), so the §2c integrity rules stay intact without special-casing.
The four opportunities are authored through the CMS on staging, not shipped as seed data. Creating Admissions Fair through the editor is the acceptance test for the editor, and it teaches staff the tool on a real example. The remaining three are then largely Duplicate + edit. Promotion to production is a documented re-entry or export/import step — see the launch guide.
| Question | Decision |
|---|---|
| Admin home for the whole feature | Everything under Signup Admin: /champion_signups/opportunities. §2b put only the responses under the Signup Admin sidebar while 21.5 shipped the CMS under /champions/opportunities in the Champions “Content” sidebar. Two admin shells for one feature is wrong. The CMS relocates into the Signup Admin shell and the responses queue lands beside it. See §2e. |
| Responses badge count | Unreviewed count. Adds a nullable reviewed_at to opportunity_responses, stamped when staff open the detail view, with a manual “mark unreviewed” toggle. This is read/unread only — not the deferred applied→confirmed→completed workflow (§5). A total-responses badge only grows and stops being a signal. |
| CSV export shape | Per-opportunity, dynamic columns. Export requires an opportunity filter; columns are contact + city/state/district + one column per field (active and retired, in field order) + champion_signup_id + buid/contact_id. The all-opportunities view offers no export — answer keys differ per opportunity, so a union CSV is sparse and grows with every new opportunity. |
| Notification audience | admin + can_portal_admin, exactly matching NotifyChampionSignupReturnJob#admin_users. Delivered via a new async NotifyOpportunityResponseJob — in-app + web push, one per response, no batching (§5). |
| Relocated CMS guard | ensure_portal_admin! on every CMS action — unchanged from what shipped. Editing a live public form is a publishing action. The Signup Admin shell’s own ensure_staff! covers the responses queue and export. The sidebar’s Opportunities (editor) item is hidden for non-portal_admins. |
Old /champions/opportunities path |
Removed cleanly — routes, views, controller, and the Champions sidebar entry. No redirect: nothing external points there and no opportunities have been authored in production. |
Supersedes the “Admin IA placement” row in §2b and the /champions/opportunities path shipped in 21.5.
Opportunities belong to the alumnichampions.com signup ecosystem (§1 scope), and the Signup Admin shell is that ecosystem’s staff home. Splitting the editor into champion_admin while the leads it produces live in signup_admin would make staff cross shells to do one job.
Carried out as part of 21.4 (the CMS is only two days old and has no production data behind it):
| Moves from | Moves to |
|---|---|
Champions::OpportunitiesController |
ChampionSignups::OpportunitiesController |
app/views/champions/opportunities/* |
app/views/champion_signups/opportunities/* |
layout "champion_admin" (via Champions::BaseController) |
layout "signup_admin" |
champions_opportunities_path etc. |
champion_signups_opportunities_path etc. |
| Champions sidebar → Content → Opportunities | Signup Admin sidebar → Opportunities section |
test/controllers/champions/opportunities_controller_test.rb |
test/controllers/champion_signups/opportunities_controller_test.rb |
ensure_portal_admin! is declared explicitly on the controller, since it no longer inherits from Champions::BaseController. Route ordering matters: the opportunities routes must be declared before resources :champion_signups, or GET /champion_signups/:id swallows /champion_signups/opportunities. Models, migrations, field_rows_controller.js, OpportunityMarkdown, and the public field partials are untouched — this is a namespace/shell move, not a rewrite.
Opportunity (DB-backed — see §2c)opportunities table:
| Column | Notes |
|---|---|
slug (string, not null, unique, indexed) |
public URL segment. Auto-generated from title; immutable once activated or once responses exist |
title (string, not null) |
staff-facing + page heading |
active (boolean, default false, not null, indexed) |
controls PUBLIC availability. Toggled instantly from the CMS — no deploy |
activated_at (datetime, nullable) |
added in 21.1. Stamped the first time active flips true and never cleared. active alone can’t enforce the slug lock: the documented rollback is deactivate → fix → reactivate, which would briefly unlock the slug of an opportunity whose link is already in the wild |
intro_md, thankyou_md, next_steps_md (text) |
markdown, rendered via Redcarpet with Cp::WelcomeContentGenerator-equivalent sanitization |
fields (jsonb, default [], not null) |
ordered array of field definitions (below) |
| timestamps |
has_many :opportunity_responses. Scopes: Opportunity.active, lookup by slug (to_param returns slug). Unknown or inactive slug → 404 on the public side.
Field definition — each element of the fields array:
{
"key": "describe_belmont", // auto-generated from label, FROZEN forever (§2c)
"type": "long_text", // one of the 5 types
"label": "How would you describe Belmont to someone who doesn't know it?",
"required": true,
"active": true, // soft-delete flag — false hides publicly, retains answers
"options": [] // select only; server-validated against submissions
}
Validated on save: every field has a unique non-blank key, a type in the closed set, a non-blank label (except contact_block), and a non-empty options array when type == "select". Exactly one contact_block per opportunity.
Field types (contact_block, short_text, long_text, select, date) each map to a render partial + a validation/coercion rule. contact_block = first/last/email/phone/zip (zip drives city/state/district lookup). Adding a new type remains a code change — that’s the fence in §2c.
Champions::OpportunitiesControllerLookup Portal, /champions/opportunities, modeled directly on Champions::SeededQuestionsController:
index, show, new, create, edit, update, destroy, plus member actions preview, activate, deactivate, and duplicate (§2c).
destroy is permitted only when the opportunity has no responses; otherwise staff deactivate. Guarded by ensure_portal_admin!.
OpportunityResponseopportunity_responses table:
| Column | Notes |
|——–|——-|
| opportunity_slug (string, not null, indexed) | which config opportunity |
| first_name, last_name, email, phone, zip_code | contact block; lead value even before any signup link. All required except phone |
| city, state (string, nullable) | derived from zip_code on save; what the staff list displays (per commit 55dd1bf3 — city, not metro district) |
| district_id (bigint, nullable, FK) | looked up from zip_code on save (reuse ZipCode.lookup, self-healing — same pattern as champion_signups). Retained for filtering/rollups, not lead display |
| privacy_policy_accepted_at (datetime, nullable) | consent capture, mirroring 19.11 on champion_signups |
| privacy_policy_version (string, nullable) | consent capture, mirroring 19.11 |
| answers (jsonb, default {}) | keyed by field key |
| champion_signup_id (bigint, nullable, FK champion_signups) | the known-submitter link: set when a returning signup submits, or when an anonymous submitter opts in and converts. Nullable so a pure-anonymous lead can exist unlinked. |
| source (string) | e.g. "opportunity_public" / "opportunity_hub" |
| timestamps | |
No unique constraint (repeats allowed). Validations: presence of opportunity_slug, contact essentials, and required fields resolved dynamically from the parent Opportunity#fields (active rows only).
belongs_to :opportunity, foreign_key: :opportunity_slug, primary_key: :slug — the slug stays the join key so a response is never orphaned by a record-id change, and the immutability rule in §2c keeps it stable.
Identity-agnostic on purpose. The response links to champion_signup_id only. It does not know about Cp::Champion. If a portal surface is ever added (§7), that’s an additive nullable champion_id column + a second controller on the same tables — not a new model.
Public::OpportunitiesController (alumnichampions.com) — the public form. show renders the form and pre-fills the contact block from the session/token/access-code signup when one is present; create writes the response and stamps champion_signup_id when a known signup submitted. Reuses SignupHoneypot spam guard + rack-attack throttle. Honors active (404 if inactive or unknown).Champions::OpportunitiesController (Lookup Portal) — the CMS (§3 above, §2c). Staff-side only.Public::ChampionSignupsController#show gains an Opportunities section listing active opportunities as deep links, pre-filled for that signup.Cp:: controller in this phase. Opportunity and OpportunityResponse are namespace-neutral models so a future portal surface can mount on them (§7). Note Champions:: is the Lookup Portal staff namespace, not Cp:: — the belmontalum.com boundary is intact.Thank-you page (public) shows the “make it official” invitation → routes into the existing Phase 19 Public::ChampionSignups flow, pre-filled from the response’s contact block (signed token carrying opportunity_response_id). On signup creation, stamp opportunity_response.champion_signup_id. No new lead pipeline — conversion rides the existing signup system.
Email-match handling (2026-07-27). On save, ChampionSignup.find_active_by_email stamps champion_signup_id when the submitted email already belongs to an active signup. This is a server-side data-quality link only and changes nothing the submitter sees:
request_link flow (ChampionSignupMailer.profile_link_email).The “keep your info up to date — we’ll email you a link” CTA is therefore shown to everyone, and behaves correctly for both cases without the page knowing which it is.
No Cp::FeatureFlag — that closed set gates belmontalum.com surfaces, untouched here. Availability is controlled per-opportunity by the active boolean, toggled from the CMS — instant, no deploy (a direct benefit of §2c). Optionally add a single ENV kill-switch (OPPORTUNITIES_ENABLED) to dark-launch the whole feature; per-opportunity active is the primary control.
ChampionSignupEvent: record opportunity_viewed / opportunity_submitted for known signups (the signup side’s own event stream — not Cp::ActivityEvent). Anonymous view analytics deferred.StaffNotification (new type opportunity_response), mirroring champion_signup_return — no email (avoid inbox flooding when a link is shared widely).Order revised 2026-07-27: 21.1 → 21.5 → 21.4 → 21.2 → 21.3. The CMS lands before the staff queue, and both land before the public form — so no anonymous PII can be captured with no way to see it, and the four opportunities are authored through the real editor. Sub-phase numbers are spec identifiers, not build order.
opportunities (slug unique-indexed, title, active, three markdown text columns, fields jsonb default []).opportunity_responses (+ indexes on opportunity_slug, district_id, champion_signup_id — no champion_id, see §2b).Opportunity ActiveRecord model (was a PORO — §2c): fields array validation (unique frozen keys, closed type set, labels, select options, exactly one contact_block), slug generation + immutability rule, active scope, to_param → slug.OpportunityResponse model: associations, dynamic required-field validation against the parent’s active fields, ZipCode.lookup assignment of city / state / district_id on save, ChampionSignup.find_active_by_email auto-link of champion_signup_id.Champions::OpportunitiesController at /champions/opportunities, modeled on Champions::SeededQuestionsController: index, show, new, create, edit, update, destroy + preview, activate, deactivate, duplicate. ensure_portal_admin!.index, new, edit, show, _form) + _status_badge, following the seeded_questions structure.field_rows_controller.js — the one new Stimulus controller. Add/remove/reorder rows; per-row label, type dropdown, required checkbox, and a conditional options editor for select.destroy blocked when responses exist./champions/opportunities removed.opportunity_responses.reviewed_at (nullable datetime, indexed) — read/unread only (§2d).champion_signups/_sidebar.html.erb, between Signups and Analytics: Opportunities (the editor, portal_admin only, “N live” badge) + Responses (unreviewed count badge).layouts/signup_admin) filtered by opportunity, district, linked-signup vs. anonymous, reviewed state, date range; sortable; paginated. Location column shows city/state, per 55dd1bf3.ChampionSignup when champion_signup_id is set. Stamps reviewed_at on open; manual mark-unreviewed toggle.StaffNotification type opportunity_response (in-app + push) via NotifyOpportunityResponseJob to admin + can_portal_admin — one per response, no batching in MVP.Csv::OpportunityResponseExporter — per-opportunity, dynamic columns (§2d): contact, city/state/district, one column per field in field order (active and retired), champion_signup_id, and (when the linked signup has a BUID) BOTH buid + contact_id, per data rules.Public::OpportunitiesController#show/#create on alumnichampions.com.contact_block + typed fields + 19.11-style consent checkbox.SignupHoneypot spam guard reuse (silent fake success) + rack-attack throttle on POST /opportunities/:slug mirroring champion-signups/ip (§2b).thankyou_md + next_steps_md, rendered via Redcarpet.request_link flow (§3).Public::ChampionSignups flow (signed opportunity_response_id); stamp champion_signup_id on signup creation.Public::ChampionSignupsController#show), listing active opportunities as deep links.:profile_access token / permanent access_code, the opportunity form pre-fills the contact block from their ChampionSignup and sets champion_signup_id on submit.ChampionSignupEvent records opportunity_viewed / opportunity_submitted.champion_signup_id set on submit, event recording.Cp:: Opportunities surface — deferred with the whole portal. The models are namespace-neutral (§7) so this becomes an additive nullable champion_id column + a second controller, not a rebuild.reviewed_at (§2d) is read/unread for the badge only, not a fulfillment state.sms_opt_in checkbox. Keeps the form short. Revisit if staff start texting opportunity leads.StaffNotification per response in MVP. If a widely shared link floods the queue despite the rate limit, collapse to “N new responses” per opportunity per hour.(update incrementally as each sub-phase ships — checkboxes, deviations, doc updates)
(build order: 21.1 → 21.5 → 21.4 → 21.2 → 21.3 — see §4)
CreateOpportunities + CreateOpportunityResponses. Indexes as spec’d (opportunity_slug, district_id, champion_signup_id), plus opportunities.active and opportunity_responses.created_at for the CMS list and the staff queue’s default sort.app/models/opportunity.rb — FIELD_TYPES closed set, field-array normalization + validation (unique keys, closed type set, labels, select options, exactly one contact_block), slug generation/uniquification, the three §2c integrity rules, active scope, to_param → slug, dependent: :restrict_with_error for the destroy guard, and readers (active_fields, question_fields, contact_block_field, field_for, label_for, slug_locked?, responses_exist?) that 21.5 and 21.4 build on.app/models/opportunity_response.rb — slug-joined belongs_to :opportunity, optional signup/district, contact validations, ZipCode.lookup city/state/district derivation (self-healing, same condition as champion_signups), server-side ChampionSignup.find_active_by_email auto-link, dynamic answer validation against the parent’s active fields (required, select options, date via Csv::DateParser, 255-char short_text cap), display_location (city-first per 55dd1bf3), answered_fields (retains answers to retired questions, in field order).test/models/opportunity_test.rb (24) + test/models/opportunity_response_test.rb (25) — 49 runs, 0 failures.activated_at column (see §3) — the slug lock can’t key off active alone.#duplicate deep-copy and markdown rendering, so 21.1 ships no code without a caller.Champions::OpportunitiesController at /champions/opportunities (slug-routed via param: :slug), all seven CRUD actions plus preview, activate, deactivate, duplicate. ensure_portal_admin! inherited from Champions::BaseController.index, show, new, edit, _form, _field_row, _status_badge, preview. Sidebar entry added to champions/_sidebar.html.erb under Content, with a “N live” badge.field_rows_controller.js — add/retire/restore/reorder rows, conditional options editor, name renumbering on every mutation. Auto-registered by eagerLoadControllersFrom; no index.js edit needed.Opportunity#duplicate_for_editing — inactive copy, fresh slug, regenerated keys, retired rows carried across, no responses.test/controllers/champions/opportunities_controller_test.rb, test/services/opportunity_markdown_test.rb.Spec deviations (2026-07-27):
OpportunityMarkdown replaces reuse of Cp::WelcomeContentGenerator.markdown_to_html. §2c called for “the same sanitization” as that method. It has none — no escape_html, no filter_html, and a closing .html_safe, so raw HTML passes straight through. That is fine for its own input (config/welcome_packs.yml, developer-authored and deploy-gated) but not for staff-typed copy on an anonymous public page, where it would be stored XSS. The new renderer sets escape_html: true and safe_links_only: true. The permissive method is unchanged and still used by welcome packs — worth a separate look, but out of scope here.app/views/opportunities/_form_fields + fields/_* are rendered by Preview now and by Public::OpportunitiesController#show in 21.2. Building them twice would let the preview drift from the real page, which defeats the point of having a preview.select options are edited as one-per-line text, not repeatable sub-rows. Keeps the row editor to a single level of nesting. Stored identically as an array.show only, not in the index row actions — the index action cell was already at four controls.Champions::OpportunitiesController → ChampionSignups::OpportunitiesController, views to app/views/champion_signups/opportunities/, layout "signup_admin", champion_signups_opportunit* route helpers, test file moved. ensure_portal_admin! declared explicitly (no longer inherited from Champions::BaseController). Routes are declared in a namespace :champion_signups block above resources :champion_signups, or GET /champion_signups/:id swallows them. Old /champions/opportunities route and Champions sidebar entry removed, no redirect. All 28 relocated tests pass unchanged apart from the module name.AddReviewedAtToOpportunityResponses — nullable reviewed_at + index. Model gains unreviewed/reviewed/anonymous/linked/for_opportunity scopes and mark_reviewed! / mark_unreviewed! (both update_column, both idempotent).champion_signups/_sidebar.html.erb, between Signups and Analytics: Opportunities (portal_admin only, “N live” badge) + Responses (unreviewed count badge).ChampionSignups::OpportunityResponsesController (index, show, mark_unreviewed, export_csv) + index/show/_reviewed_badge views. Filters: opportunity, district, on-file vs. anonymous, review state, date range; sorts newest/oldest/name/location; paginated. Location column reads city/state per 55dd1bf3. Opening show stamps reviewed_at.NotifyOpportunityResponseJob + StaffNotification type opportunity_response — in-app + web push to admin + can_portal_admin, one per response, no email.Csv::OpportunityResponseExporter — per-opportunity, dynamic columns in field order including retired fields (labeled (retired)), plus contact, city/state/district, consent, reviewed_at, champion_signup_id, and both buid + contact_id when the linked signup has a BUID. The all-opportunities view offers no export; export_csv refuses without an opportunity param.Public::OpportunitiesController#show/#create/#thank_you on alumnichampions.com, routed inside the existing nested domain constraint. thank-you is declared before :slug so it isn’t swallowed. Inactive and unknown both raise RoutingError → 404.opportunities/_form_fields — the same partial Preview uses, so the two can’t drift. SignupHoneypot reuse (silent fake success, no record, no notification) + a new opportunity-responses/ip rack-attack throttle (5 / 30 min) mirroring champion-signups/ip.privacy_policy_accepted_at + _version).thankyou_md + next_steps_md, is identical for matched and unmatched submitters, never links into the hub, and offers the 19.7 request_link “email me a link” form to everyone.OpportunityResponse#conversion_token (signed, 7-day) → new_public_champion_signup_path(step: "who_you_are", opportunity_response: …); #new pre-fills from to_signup_attributes, the hidden field carries the token through the POST, and handle_who_you_are stamps champion_signup_id on creation.test/controllers/public/opportunities_controller_test.rb (covers 21.2 and 21.3), including a byte-comparison of the matched vs. unmatched thank-you pages once per-request tokens are normalized out.Public::ChampionSignupsController#show loads Opportunity.active.order(:title)), active only, hidden entirely when nothing is live. Links carry signup_token.Public::OpportunitiesController resolves the visitor’s own signup from the profile token or the session — never an email lookup — pre-fills the contact block, sets source: "opportunity_hub", and attributes champion_signup_id.ChampionSignupEvent gains opportunity_viewed / opportunity_submitted (metadata: opportunity_slug). Anonymous traffic records nothing.opportunity_responses.signup_link_method records HOW the signup link was made: verified (profile token or active session — they submitted from their own hub) vs email_match (inferred from the address alone). champion_signup_id alone conflated proof with a guess, which the thank-you page and the staff queue both needed to distinguish. Conversion via the signed token counts as verified and upgrades an existing email match.current_signup), never the stored flag, so a stale session can’t unlock it.Csv::OpportunityResponseExporter gained a signup_match column so an outreach list can’t read a guess as a confirmation.max-w-5xl, two columns on desktop, one on mobile): main column = things to do, sidebar = contact details + alumni team. Adaptive lead — incomplete profile leads with “About you”, complete profile leads with “Ways to show up” and collapses the finished sections to an “All set” strip. Missing ZIP still hoisted full-width. The standalone “why more info helps” surface folded into the heading + progress bar it explained.Opportunity#slugify used parameterize(separator: "_"), which preserves hyphens already in the string — its regex whitelists - and _ alongside alphanumerics. A title like “Send-Off Party” generated send-off_party, which then failed the model’s own slug format validation, so a perfectly ordinary title couldn’t be saved. Surfaced by a 21.2 test fixture, not by 21.1’s tests, which only used hyphen-free titles. Fixed at the root (fold hyphens → underscores, squeeze runs, trim edges) with two regression tests. Why it wasn’t caught: every 21.1/21.5 slug test used single-word or space-separated titles, so the one character class that survives parameterize was never exercised. Logged as a gotcha in /debug and .github/copilot-instructions.md.
docs/CHANGELOG.md, champions/roadmap_controller.rb (new Phase 21 entry, all five sub-phases :complete), docs/features/ALUMNI_OPPORTUNITIES.md (new), docs/development/MODEL_RELATIONSHIPS.md, docs/planning/qa/PHASE_21_LAUNCH_GUIDE.md, .claude/skills/debug/SKILL.md, .github/copilot-instructions.md.
Author the four opportunities through the CMS (Admissions Fair fully; Watch Party / Speaking / Send-Off via Duplicate + edit; only Admissions Fair activated). That remains the acceptance test for the editor — see the launch guide.
This phase is where alumnichampions.com starts to feel like an app people return to. That’s exactly where the risk of accidentally building a second Alumni Network lives. The guardrails below govern this phase and the ones after it.
| | ChampionSignup (alumnichampions.com) | Cp::Champion (belmontalum.com) |
|—|—|—|
| Auth | Bearer link — possession of an emailed token or permanent access_code. Not a password. | Real account: Devise password / Google SSO. |
| Role | Intake / top-of-funnel / lead enrichment. | Home / authenticated product. |
| Lifecycle | Provisional. Graduates into a Champion account. | The destination. |
There is already a bridge: Cp::ConfirmationsController#find_legacy_signup_for matches a signup by email at account creation and pre-fills the Champion via ChampionSignup#to_champion_attributes. The intended funnel is signup → Champion account, and it already exists.
The signup-side profile is for a person managing their own record. Anything whose value comes from other people belongs to the authenticated portal.
Cp::Champion / Devise. Do not build a directory on ChampionSignup; bearer links mean “anyone with the link is you,” which is fine for editing your own record and unacceptable for browsing others.Keep every new concept identity-agnostic and forward-mapping, so reconciliation is a migration, never a rewrite:
OpportunityResponse links to a person via a nullable FK and lives in its own table (not columns on champion_signups). Adding a portal surface later = a nullable champion_id + a second controller on the same table/config. Built right, the portal gets Opportunities “for free.”ChampionQuizService both sides use; result_role is retained for compatibility; to_champion_attributes maps the result forward. Shared service + shared vocabulary + forward map. When the portal resumes, swap its role-crowning UI for ChampionQuizService.interest_spectrum — additive, low-risk.Every column added to champion_signups that duplicates a cp_champions column is future merge work (name, zip, phone, city/state, role/interests already overlap). Mitigations, applied as a standing rule:
ChampionSignup#to_champion_attributes the single signup→Champion mapping; extend it the moment you add a signup field with a Champion equivalent.champion_signups.Land shared foundations on the shared side of the code so both front doors can use them without a portal build:
Opportunity → app/models/opportunity.rb; OpportunityResponse → app/models/opportunity_response.rb. Not under public/ or cp/. (Per §2c both are now ActiveRecord models; the CMS controller lives in the Lookup Portal’s Champions:: staff namespace, which is not Cp:: and does not cross the belmontalum.com boundary.)ChampionQuizService / ChampionRoleService (already true).Standing rule for future phases: before adding a feature to the signup side, classify it as intake / conversion / lead-enrichment (belongs here) or relational / social / a place people live (belongs to the portal). If it’s the latter, that’s the signal to resume belmontalum.com rather than extend alumnichampions.com.