alumni_lookup

Phase 21 — Alumni Opportunities

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)


1. Overview

Problem

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.

Solution

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.

Why this beats a Google Form (scope justification)

  1. Signed-in Champions get zero-friction, pre-filled forms.
  2. Built-in conversion loop: an anonymous form-filler becomes a Champion signup with their data carried forward.
  3. Responses land in staff tools with district lookup and (post-conversion) BUID linking — not a disconnected spreadsheet.
  4. Owned + on-brand experience aligned to LANGUAGE_STYLE_GUIDE.md.
  5. Responses become part of a Champion’s activity history.

What this is NOT


2. Decisions (Planning Interview, 2026-07-23)

Question Decision
Opportunity definition storage Hardcoded config (YAML) at 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). SUPERSEDED 2026-07-27 — see §2c. Replaced by a DB-backed admin CMS.
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.

2b. Decisions (Preflight Re-Review, 2026-07-27)

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 field requirements

contact_block = first_name, last_name, email, zip_code required; phone optional — matching signup step 1 (who_you_are).

Field-type schema details (not covered by the original interview)


2c. Spec Deviation — Opportunity CMS (2026-07-27, approved)

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.

Why the change

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.

Scope fence — a row editor, not a form builder

§1’s “What this is NOT” still holds. The questions editor is deliberately constrained:

What this removes from scope

What this adds

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.

Data-integrity rules (mandatory — these are the CMS’s sharp edges)

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.

Duplicate action (added 2026-07-27)

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.

Seeding

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.


2d. Decisions (21.4 Planning Interview, 2026-07-27)

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.

2e. Spec Deviation — CMS relocates to Signup Admin (2026-07-27, approved)

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.


3. Architecture

Model: 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.

CMS: Champions::OpportunitiesController

Lookup 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!.

Model: OpportunityResponse

opportunity_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.

Controllers

Conversion (anonymous → Champion)

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:

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.

Availability control

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.

Activity + notifications


4. Sub-Phases

Order revised 2026-07-27: 21.1 → 21.521.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.

21.1 — Data Model Foundation (build 1st)

21.5 — Opportunity CMS (build 2nd — NEW, see §2c)

21.4 — Staff Management (Queue, Notify, Export) (build 3rd — moved up)

21.2 — Public Opportunity Pages (Anonymous) (build 4th)

21.3 — Profile-Hub Integration (returning signups) (build 5th)


5. Open Items / Deferred


6. What Was Implemented

(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)

Bug found and fixed during 21.2 (2026-07-27)

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.

Documentation updated

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.

Still to do on staging

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.


7. Strategic Context — Two Front Doors, One Model

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.

The two identity models (name them, don’t blur them)

| | 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 line that prevents a duplicate network

The signup-side profile is for a person managing their own record. Anything whose value comes from other people belongs to the authenticated portal.

Why this phase is convergence-safe (and the rule that keeps it so)

Keep every new concept identity-agnostic and forward-mapping, so reconciliation is a migration, never a rewrite:

  1. OpportunitiesOpportunityResponse 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.”
  2. Roles → Interests — already the model to copy: the spectrum is computed by the same 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.
  3. Signup flow — the hub-and-spoke “complete-at-step-1, optional enrichment” pattern and the warm, source-aligned copy are better than the portal’s older Phase 1.4 wizard. When belmontalum resumes, converge the portal’s profile-completion onboarding on this pattern rather than the reverse.

The reconciliation debt to watch

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:

Reintegration-while-on-hold (do this cheaply now)

Land shared foundations on the shared side of the code so both front doors can use them without a portal build:

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.