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.
| # | 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_hub → verified, 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.
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:
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.
| 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.
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:
LANGUAGE_STYLE_GUIDE.md21.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:
When: / Where: / The ask: lines from Intro into the new Event
details fields, and delete them from the intro — otherwise the page shows bothThe single most important behavior to verify. Submitting an opportunity form with someone else’s email must never grant access to that person’s record.
admissions_fair anonymously using the email of an existing ChampionSignup/sign-up/:id or /profile/:codechampion_signup_id set) even though the page revealed nothingprivacy_policy_accepted_at and privacy_policy_version are written on every responseconfig/routes.rb:52)champion-signups/ip)GET of the formcity, state, district_id all populated from ZipCode.lookup55dd1bf3)intro_md / thankyou_md / next_steps_md renders through OpportunityMarkdown (not Cp::WelcomeContentGenerator — see 21.5 deviation 1 in the spec; that renderer does not sanitize)<script>alert(1)</script>, <img src=x onerror=alert(1)>, and [x](javascript:alert(1)) into intro_md; confirm all three appear as inert visible text with no live element and no anchor. Covered by test/services/opportunity_markdown_test.rb, but verify once in a real browserselect rejects a value not in its options when posted directly (server-side, not just the <select>)destroy is blocked when responses exist; deactivate is offered insteadselect row requires at least one option before savecontact_block enforced — can’t save zero or twoensure_portal_admin! guard: a staff-only user cannot reach the CMS (it now lives at /champion_signups/opportunities — the guard is declared on the controller, not inherited)/champions/opportunities path is gone (404) and no longer appears in the Champions sidebarstaff-only user sees Responses but not the editor link55dd1bf3)reviewed_at; reloading does not move it; Mark unreviewed puts it back in the badgeChampionSignup when one is linked, and says “new name to us” when notStaffNotification type opportunity_response fires in-app and push, no emailexport_csv without one redirects with an explanatory alert(retired), and BOTH buid and contact_id when the linked signup has a BUID (data rule)/sign-up/:id/opportunities/:slug/thank-you directly in a fresh browser shows the anonymous version, never the personalized onechampion_signup_id set, source: opportunity_hub, and signup_link_method: verifiedopportunity_viewed / opportunity_submitted appear in the signup’s Activity timelinesignup_match column reads confirmed / email_match / blank to matchCheckbox questions
Link-only visibility
listed never overrides active.Notification email — the one to verify carefully, since it is the reversal of 21.4’s no-email rule
notify_emails blank, submit a response, and confirm no email is sent and the admin in-app notification still arrives. This is the default state of every existing opportunity and the whole safety mechanism.buid and contact_id.Print / Save as PDF (21.7)
Display
When · Where; an opportunity with neither falls back to the question countExpiry — the highest-risk part of 21.8
thank_you ever picks up the expiry filterHub ordering
Receipt email (opt-in)
alumni@, not noreply@, and replying reaches a humanHub grouping
SEP / 30); undated cards have none+ under the dayCards
Venue / address split — the reason to test on real content
Ongoing
Mobile (375px)
opportunity_response.champion_signup_idopportunity_response_id token cannot be tampered with to claim a different response| 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.
# 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
Still outstanding. Do this on staging before activating anything in production:
/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.| 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. |
Before wrapping the phase, confirm the reconciliation-safety rules held:
Opportunity and OpportunityResponse live at app/models/ — not under public/ or cp/Cp:: controller, view, or route was added. Both staff controllers live in ChampionSignups:: (Signup Admin) — the signup ecosystem’s own shell, not a belmontalum.com surfacechampion_signupsChampionSignup holder see other people — bearer links stay scoped to a person’s own record