alumni_lookup

Phase 21 Launch Guide — Alumni Opportunities

Phase spec: phases/phase-21/README.md Scope: alumnichampions.com signup ecosystem (Public::) + Signup Admin. Does not touch belmontalum.com / Cp::. Build order: 21.1 → 21.5 → 21.4 → 21.2 → 21.3 (CMS and staff visibility both ship before the public form) Status: ✅ All five sub-phases built and merged 2026-07-27. This guide is now the deploy + QA checklist, not a plan.

Design change 2026-07-27: opportunity definitions are DB-backed with an admin CMS, not config/opportunities.yml. See §2c of the spec. This guide reflects the CMS design.

Update this guide as you build. Every migration, rake task, and placeholder URL gets added here the moment it’s created.


1. Pre-Deployment: Migrations

# Migration Sub-phase Risk Notes
1 CreateOpportunities 21.1 🟢 Low ✅ Written. New table. Unique index on slug, plus an index on active. fields jsonb default [] not null; active boolean default false not null. Includes activated_at — the slug lock keys off it, not active (spec §3).
2 CreateOpportunityResponses 21.1 🟢 Low ✅ Written. New table. Indexes: opportunity_slug, district_id, champion_signup_id, created_at. FKs to districts and champion_signups are nullable.
3 AddReviewedAtToOpportunityResponses 21.4 🟢 Low ✅ Written. Adds nullable reviewed_at + index to a table that is empty until 21.2 goes live. Read/unread for the sidebar badge only — not a fulfillment state.
4 AddSignupLinkMethodToOpportunityResponses 21.6 🟢 Low ✅ Written. Adds nullable signup_link_method + index, and backfills existing linked rows from source (opportunity_hubverified, else email_match). No rows exist in production yet, so the backfill is belt-and-braces.
5 AddListedAndNotifyEmailsToOpportunities 21.7 🟢 Low ✅ Written. Adds listed (boolean, default true, not null) and notify_emails (text, nullable) to opportunities, plus a partial index on (active, listed) WHERE active AND listed. Both defaults reproduce current behavior exactly: every existing opportunity stays listed, and a null notify_emails sends no mail. No backfill step.
6 AddEventDetailsToOpportunities 21.8 🟢 Low ✅ Written. Adds starts_at, ends_at, when_note, location, ask_text (all nullable) and send_receipt_email (boolean, default false, not null) to opportunities, plus an index on starts_at. Every default reproduces current behavior: an undated opportunity never expires, and a receipt is sent only where staff tick the box. No backfill — see §4.
7 RemoveWhenNoteFromOpportunities 21.9 🟡 Low-medium ✅ Written. Drops when_note, added one release earlier in migration 6 — an undated opportunity now derives “Ongoing” instead. This is a column drop: old dynos calling when_note during the release window will raise. The window is seconds on this app, and the column is ~one release old, so any data in it is a few staff keystrokes. If staff have used it in earnest, back it up first.

Migrations 5 and 6 add columns to opportunities (21.7, 21.8); migrations 1–4 add no columns to any pre-existing table. champion_signups is untouched throughout — per §7 of the spec, new concepts get their own identity-agnostic tables rather than new columns on champion_signups.

Rollback: rails db:rollback STEP=6 unwinds all six cleanly. No data dependency in either direction until 21.2 goes live. Rolling back only migration 6 (STEP=1) is safe on a live system only if no opportunity has been given a date — it drops six columns and with them the expiry behavior, so a dated opportunity that was 404ing becomes reachable again. Rolling back 5 and 6 together (STEP=2) reverts opportunities to “all listed, no email”, the pre-21.7 behavior.


2. Pre-Deployment: Rake Tasks

None required. No backfill exists to run — both tables start empty, and the four opportunities are authored through the CMS rather than seeded (§2c).

Staging → production promotion of opportunity content is the one manual step. Options, in order of preference:

  1. Re-enter through the CMS on production (~10 min for Admissions Fair, then Duplicate for the rest). Simplest, no tooling, and it re-verifies the editor in prod.
  2. rake opportunities:export[slug] / opportunities:import — a small JSON round-trip task. Build this only if re-entry proves annoying; it is not in scope by default.

Do not copy rows between databases by hand — slug uniqueness and the frozen field keys must stay coherent.


3. Content Needing Staff Verification

Item Where Needed before
Admissions Fair copy (intro_md, thankyou_md, next_steps_md) + the 4 essay questions CMS → /champion_signups/opportunities 21.2 public launch
Watch Party / Speaking / Send-Off CMS (via Duplicate + edit) Only when each is activated
Privacy policy version string consent capture 21.2 — must match the value 19.11 writes to champion_signups.privacy_policy_version

Only Admissions Fair should be active at launch. The other three stay inactive (404 publicly) until staff author their copy. Because active is a DB boolean toggled from the CMS, this is now a click rather than a deploy.


4. Content Dependencies

The feature is inert without staff action — there is no public index page by design, so an opportunity only gets traffic when staff share its deep link:

https://alumnichampions.com/opportunities/admissions_fair

Before announcing:

21.8 — move the event details out of the intro copy. There is deliberately no automated backfill: parsing free-form markdown on live copy is guesswork, and there is one opportunity to fix. After deploy:


5. Sensitive Features / QA Focus Areas

5a. Anonymous access boundary (highest risk in this phase)

The single most important behavior to verify. Submitting an opportunity form with someone else’s email must never grant access to that person’s record.

5c. Spam / rate limiting

5d. Location derivation

5e. Config-driven rendering

5h. CMS (21.5) — the new surface

5f. Staff surface (21.4)

5i. Public form + thank-you (21.2 / 21.6) — verify by hand once

5j. Profile hub (21.3 / 21.6)

5k. Identity confidence (21.6) — the distinction staff act on

Checkbox questions

Link-only visibility

Notification email — the one to verify carefully, since it is the reversal of 21.4’s no-email rule

Print / Save as PDF (21.7)


5m. Event details, expiry, and the receipt email (21.8)

Display

Expiry — the highest-risk part of 21.8

Hub ordering

Receipt email (opt-in)


5n. Event styling, grouping, and the venue split (21.9)

Hub grouping

Cards

Venue / address split — the reason to test on real content

Ongoing

Mobile (375px)


5g. Conversion loop


6. Sub-Phase Deployment Stages

Stage Sub-phase Independently deployable? Public impact
1 21.1 Data model ✅ Yes None — two empty tables, nothing routed
2 21.5 Opportunity CMS ✅ Yes None — staff-only. Content authoring happens here
3 21.4 Staff management ✅ Yes None — staff-only surface, empty list
4 21.2 Public pages ✅ Yes Live. First point at which anonymous PII is captured
5 21.3 Profile-hub integration ✅ Yes Adds the hub section for returning signups

Stages 1–3 are zero-risk deploys — nothing is publicly reachable, because active defaults to false and no public route exists until stage 4. Stage 4 is the real launch gate; do not deploy it until §5a, §5b, and §5h pass on staging.

Kill switch (built): OPPORTUNITIES_ENABLED=false darks the whole feature — public pages 404 and the hub section disappears — via Opportunity.publicly_available. Unset defaults to on, so it can never dark the feature by omission. The per-opportunity active toggle in the CMS remains the primary control; the env var exists only for an emergency global off.


7. Post-Deployment Verification

# Only admissions_fair should be active at launch
echo 'Opportunity.active.pluck(:slug)' | bin/rails console

# Field definitions survived the CMS round-trip
echo 'Opportunity.find_by(slug: "admissions_fair").fields.map { |f| f.values_at("key", "type", "required") }' | bin/rails console

# After the first real submission
echo 'OpportunityResponse.last.slice(:opportunity_slug, :city, :state, :district_id, :champion_signup_id, :privacy_policy_accepted_at)' | bin/rails console

# Notification fan-out reached staff
echo 'StaffNotification.for_type("opportunity_response").count' | bin/rails console

# Unreviewed count — this is what the sidebar badge shows
echo 'OpportunityResponse.unreviewed.count' | bin/rails console

# Kill switch is OFF (feature on) in this environment
echo 'Opportunity.feature_enabled?' | bin/rails console

# Confidence split on linked responses — email matches should NOT read as confirmed
echo 'OpportunityResponse.group(:signup_link_method).count' | bin/rails console
# Active opportunity renders
curl -sI https://alumnichampions.com/opportunities/admissions_fair | head -1   # 200

# Inactive and unknown slugs both 404
curl -sI https://alumnichampions.com/opportunities/watch_party | head -1       # 404
curl -sI https://alumnichampions.com/opportunities/not_a_thing | head -1       # 404

# Must NOT be reachable on the portal domain
curl -sI https://belmontalum.com/opportunities/admissions_fair | head -1       # 404

7b. Staging Content Authoring (the editor’s acceptance test)

Still outstanding. Do this on staging before activating anything in production:

  1. Author Admissions Fair end to end through /champion_signups/opportunities — contact block + the 4 essay questions + all three markdown blocks. Building it through the real editor is the acceptance test for the editor.
  2. Duplicate it three times and edit into Watch Party / Speaking / Send-Off.
  3. Activate only Admissions Fair.
  4. Promote to production by re-entering through the CMS (§2) — do not copy rows between databases.

8. Rollback Plan

Situation Action
Copy is wrong / opportunity launched early Deactivate from the CMS — instant, no deploy. Slug 404s immediately; captured responses are retained. Fix the copy and reactivate.
Feature-wide problem OPPORTUNITIES_ENABLED=false on Heroku — no deploy needed.
Spam flood despite throttle Tighten the rack-attack limit (config change + deploy), or deactivate the opportunity (instant). If notifications are the problem, that’s the trigger to build the deferred batching (§5 of the spec).
Schema problem rails db:rollback STEP=4 unwinds to before the phase. Only safe before real responses exist — after launch, export via Csv::OpportunityResponseExporter first.
Staff broke a live form by editing fields The §2c rules make this largely non-destructive by construction: keys are frozen, deletes are soft, and the slug is locked. Reactivate the field or restore the label; answers were never lost.
Staff deleted an opportunity destroy is blocked once responses exist. A no-response opportunity that’s deleted is genuinely gone — recreate it, or Duplicate from a similar one.

9. Convergence Guardrail Check (§7 of the spec)

Before wrapping the phase, confirm the reconciliation-safety rules held: