Phase: 21 (complete, 2026-07-27; extended 21.8 and 21.9, 2026-08-14)
Surface: alumnichampions.com signup ecosystem + Signup Admin. No belmontalum.com / Cp:: surface.
Spec: phases/phase-21/README.md · Launch guide: PHASE_21_LAUNCH_GUIDE.md
An Opportunity is a concrete thing an alum can sign up to do — represent Belmont at an admissions fair, host a game watch party, speak to prospective students, host a send-off party. Staff author each one in the admin: a title, a shareable link, three blocks of markdown copy, and a short list of typed questions. The result is a public form at:
https://alumnichampions.com/opportunities/:slug
Anyone with the link can fill it in — no account, no prior signup. Each submission becomes an OpportunityResponse that staff can filter, read, get notified about, and export.
reviewed_at is read/unread for the sidebar badge only.| Piece | Where |
|---|---|
Opportunity, OpportunityResponse |
app/models/ — namespace-neutral on purpose |
| CMS (author + publish) | SignupAdmin::OpportunitiesController → /signup_admin/opportunities (portal_admin) |
| Responses queue | SignupAdmin::OpportunityResponsesController → /signup_admin/opportunity_responses (staff) |
| Public form | Signup::OpportunitiesController → /opportunities/:slug (anonymous) |
| Profile-hub section | Signup::SignupsController#show → “Ways to show up” |
| Markdown renderer | OpportunityMarkdown (.render for HTML, .plain_text for text email) |
| Event details block | app/views/shared/_opportunity_event_details.{html,text}.erb — variants :page, :card, :email |
| Hub opportunity card | app/views/signup/signups/_opportunity_card.html.erb — date-bar and plain shapes |
| Export | Csv::OpportunityResponseExporter |
| Notification | NotifyOpportunityResponseJob → StaffNotification type opportunity_response, plus OpportunityMailer |
| Notification email content | OpportunityResponseDigest (shared service) |
| Alum’s confirmation email | OpportunityMailer#submission_receipt (opt-in per opportunity) |
| Row editor | app/javascript/controllers/field_rows_controller.js |
Both staff controllers live in the Signup Admin shell because Opportunities belong to the signup ecosystem — the editor sits beside the leads it produces rather than making staff cross admin shells to do one job.
intro_md (above the form), thankyou_md (after submitting), next_steps_md (“what happens next”).Add question rows. Every opportunity has exactly one contact block (first/last/email/ZIP required, phone optional — matching signup step 1); everything else is one of:
| Type | Notes |
|---|---|
short_text |
255-char cap |
long_text |
essay, no cap |
select |
pick one; needs at least one option; the value is validated server-side, not just by the <select> |
checkbox |
pick any; needs at least one option; stores an array, and every ticked value is validated server-side |
date |
parsed via Csv::DateParser; no min/max |
active. Instant, no deploy. Turn off 404s the link immediately and keeps every response already collected.Editing a form people have already answered is where a CMS like this normally destroys data. Three rules make it non-destructive by construction, and they’re enforced in the model rather than the UI:
answers is stored under never moves. Rewording a question keeps every prior answer readable.(retired).Delete is offered only while an opportunity has zero responses. Past that, deactivate.
champion_signup_id alone doesn’t say enough, so signup_link_method records how the link was made:
| Value | What happened | How much you can trust it |
|---|---|---|
verified |
They arrived with a profile token or an active signup session and submitted from their own hub | This is them |
email_match |
The address they typed belongs to a signup on file | Probably them. Nobody confirmed it — shared inboxes, couples and typos all land here |
Conversion through the thank-you page counts as verified (they completed the signup themselves off a signed token) and upgrades an existing email match.
Everything downstream keys off this distinction rather than off “is it linked”.
An opportunity form is public and anonymous. Submitting one with someone else’s email must never surface or grant access to that person’s record, or the form becomes a backdoor login.
So an email_match link is a server-side data-quality record only, and nothing about it reaches the page:
A verified submitter is a different story and is greeted by name and sent back to their own hub — they already had access to it, so nothing is being disclosed. Crucially, that branch requires live proof on the request (current_signup resolving a token or session), never the stored signup_link_method. A stale session pointer to a response can’t unlock it.
Pre-filling follows the same rule: only when the visitor already proved who they are. Never an email lookup.
A test asserts the matched and unmatched anonymous thank-you pages are byte-identical once per-request tokens and the submitter’s own echoed input are normalized out.
Two layers, mirroring the signup flow:
SignupHoneypot) — off-screen field plus a signed render timestamp. A trip gets a silent fake success: the bot receives the same redirect a human would, but nothing is written and nobody is notified.rack-attack throttle opportunity-responses/ip, 5 submissions per 30 minutes per IP, mirroring champion-signups/ip. Repeat submissions are legitimate (someone may host several watch parties) but come days apart, so the limit never bites a real person.Three fields, separate from the markdown copy, because staff were typing them as
When: / Where: / The ask: lines at the top of intro_md — where they were
formatted differently on every form, invisible everywhere but the form itself,
and impossible to sort or expire by.
| Field | Column(s) | Notes |
|---|---|---|
| When | starts_at, ends_at |
A range is optional. Leaving both blank is a meaningful answer, not a gap — see below |
| Where | location |
An address, a room, or “Various locations” |
| The ask | ask_text |
One line on what saying yes actually involves |
Where and The ask are plain text, not markdown — they are labeled values,
and intro_md keeps the markdown job. A blank one is omitted entirely: no
label, no “TBD”.
When is never omitted. An opportunity with no dates reads “Ongoing”.
21.8 shipped a when_note column so staff could type that themselves; 21.9
dropped it, because the only thing the note ever had to express was “there is no
date” and the schema already knows that. Deriving it removes a field staff had to
think about, and removes the state where a typed note and the dates disagreed.
location is one field, and its first line is the venue — everything after
it is the address:
New Heights Brewing Company <- bold
928 Rep. John Lewis Way S <- smaller, quieter, joined with ", "
Nashville, TN 37203
-> View Map link
This is a display convention, not two columns, and the payoff is the case it
leaves alone: a one-line location like “Various locations” renders as a
single bold line with no address — and therefore no map link, because the
link keys off address_line rather than off the location being present. Staff
decide whether there’s a map by whether they type an address.
| Variant | Used by | Shape |
|---|---|---|
:page |
public form, staff preview, thank-you page | Calendar and map-pin icon rows — date above time, venue above address, then View Map. “The ask” keeps a label: it has no natural glyph, and it’s a sentence rather than a fact |
:card |
hub opportunity cards | Time and place rows only. No date — it’s in the blue date bar beside them, and printing it twice is the likeliest regression here |
:email |
receipt + staff notification | Inline-styled table; Tailwind does not survive a mail client |
The :page and :card treatments follow the Alumni Network’s event pages
deliberately — that’s the visual language alumni already read as “a thing
happening at a time in a place.”
publicly_available filters on unexpired. Once the end of the day
ends_at || starts_at falls on has passed, the public link 404s and the
opportunity drops off the profile hub — end-of-day rather than the timestamp, so
a 10am breakfast stays reachable while people are still walking in.
An undated opportunity never expires, which is what makes this a no-op for
everything authored before 21.8. dated? is the single switch behind all of it:
it decides which hub group an opportunity lands in, whether it can expire, and
whether its date renders as “Ongoing”.
Two consequences worth knowing:
publicly_readable, which is active
and the feature flag without the expiry filter. Otherwise everyone who
submitted on the final day — the busiest day — would lose their own receipt at
midnight, and the confirmation email links to that page. active still closes
every page, which is what that switch is for.Ended badge in place of Live, on the index, detail, and
edit screens, plus a line on the preview banner. A form going dark with nothing
on screen to explain it reads as a bug. The CMS itself resolves with
find_by!, so an expired opportunity stays fully editable.The hub also orders by by_soonest — soonest date first, undated last, ties by
title — because with a date on the card, alphabetical order reads as arbitrary.
The hub splits the list on dated?:
starts_at. Gets a blue date bar on its card.They answer different questions — am I free that day? vs. do I want to? — and sorting them together implies a deadline the second kind doesn’t have. Each heading appears only when the other group is non-empty, so a hub with just one kind looks exactly as it did before the split.
send_receipt_email, off by default, sends the submitter their own copy: the
details, the thank-you and next-steps copy, and their answers. Opt-in per
opportunity for exactly the reason the notify list is — a widely shared link must
not start mailing strangers because somebody added a date to the form.
It sends from the friendly alum-facing alumni@ address rather than
OpportunityMailer’s black-holed staff noreply@.
What it may not contain: no profile link, no signup token, no detail drawn
from a matched ChampionSignup, and no acknowledgment that a matching record
exists. The address came off an anonymous public form and may not belong to the
person who typed it — same rule as the thank-you page below. Everything in the
email is either the opportunity’s own public copy or something this submission
put on the page, so a misdirected copy discloses only what the sender typed.
Every submitter sees the opportunity they signed up for named in the page furniture (not left to whatever the staff-authored copy happens to mention), a repeat of the event details (21.8 — this is the moment they commit, and the date otherwise lived only on the page they just left), plus a “What you sent us” receipt of the contact details and answers just submitted — session-scoped, so it’s their own data read back to them, and a chance to spot a typo before staff follow up.
Where it sends them next depends on proof:
| Verified submitter | Anonymous (incl. email match) | |
|---|---|---|
| Greeting | “Thanks, {first name}” | “Thank you” |
| Primary CTA | Back to your profile | Count me in for good (conversion) |
| “Already signed up with us?” | Hidden — they plainly are | Shown, emails a link |
Neither branch is a dead end.
The thank-you page invites anonymous submitters to “make it official”. The link carries a signed, 7-day reference to the response they just submitted (OpportunityResponse#conversion_token), so:
to_signup_attributes.champion_signup_id is stamped on the response.Signed rather than a bare id, because the token pre-fills a form with someone’s contact details.
Signup Admin → Opportunities → Responses.
ChampionSignup when one is linked.Two audiences, deliberately different in kind. Both fire from NotifyOpportunityResponseJob, and they are independent — a mail failure cannot cost staff their queue badge, or the reverse.
In-app + web push. Always. One StaffNotification per response to admin plus anyone with can_portal_admin, matching NotifyChampionSignupReturnJob’s audience. Reaches only people with a Lookup Portal account, which is the point: it is the staff queue’s own badge.
Email. Opt-in, per opportunity. Each opportunity carries its own comma-separated notify_emails list, editable in the CMS. Anyone can be on it — the person who owns a given opportunity is often a board liaison or an event organizer with no reason to have a portal account. Recipients are BCC’d, because the list is usually a mix of staff and outside partners. Replies go to the submitter, which is the most common next action.
Recipients without a Lookup Portal account do not get the “open in the staff queue” link — it would only land them on a login screen. When a list mixes portal users and outsiders, delivery splits into two emails (one per audience); a list that is all one or all the other stays a single email. The link is the only difference between the variants.
Each email carries the submitted answers (retired questions included), the contact block, and — when the response is linked — the Champion record: graduation year, industry, job title, employer, interests, affinities resolved to names, and the matched alum’s degrees. Assembly lives in OpportunityResponseDigest rather than the view, so a three-hop walk across four models is testable without rendering.
What that section deliberately leaves out: location and district (the contact block above already answers “where are they” from what the alum just typed — repeating the address on file puts two possibly-disagreeing answers side by side), and BUID, BQID and the signup date. Those last three are reconciliation data; the CSV export still carries both ids per the project data rule. This section answers who is this person, and anything that doesn’t help a human decide how to respond is noise.
A match made on email address alone is labeled unconfirmed in the email body. Shared inboxes, couples and typos all produce email matches; a guess must never read to staff as a proven identity. Same distinction the queue and the CSV carry.
Blank notify_emails sends nothing, and that is the default. Phase 21.4 shipped push-only precisely because an opportunity link can be shared widely and a burst must never flood an inbox. Opt-in preserves that reasoning rather than discarding it: mail only goes out on a form somebody deliberately configured, so the flood risk is scoped to forms a human is watching. Every opportunity that predates 21.7 behaves exactly as it did.
No batching; if a shared link floods the queue despite the throttle, that’s the trigger to collapse to “N new responses” per opportunity per hour.
Signup Admin → Responses → open one → Print / PDF. Opens a print-styled page in a new tab; the browser’s own Save-as-PDF destination produces the file. No PDF library is involved — a deliberate choice given this app’s documented Heroku memory constraints, and it means the document is styled with the same Tailwind as everything else.
It contains what the notification email contains, assembled live. A response that has been linked to a Champion since the email went out prints with the record we hold today, which is the reason it is a page rather than an archived copy of what was mailed.
Two details that matter when one of these gets forwarded:
Printing does not mark a response reviewed — show already owns that, and reprinting something you have read shouldn’t move a timestamp.
One opportunity at a time. Columns are dynamic: contact + city/state/district + consent + reviewed_at, then one column per question in the opportunity’s own field order (retired ones included and marked), then champion_signup_id, a signup_match column (confirmed / email_match / blank) so a list handed to whoever does outreach can’t read a guess as a confirmed identity, and — when the linked signup has a BUID — both buid and contact_id, per the project data rule.
A union CSV across all opportunities is deliberately not offered: answer keys differ per form, so it would be sparse and grow a column every time staff author a new one. The export button stays hidden until an opportunity is selected, and the server refuses without one.
| Lever | Effect | Speed |
|---|---|---|
Per-opportunity active toggle (CMS) |
That link 404s; responses retained | Instant, no deploy |
Per-opportunity listed toggle (CMS) |
Hidden from the profile hub; the link still works | Instant, no deploy |
| Event end date passing (21.8) | That link 404s and it leaves the hub; the thank-you page keeps working | Automatic, at end of day |
OPPORTUNITIES_ENABLED=false |
Whole feature darks — every public page 404s, hub section disappears | Config change, no deploy |
listed is a display flag, not an access flag — it is the difference between a form we advertise and a form staff hand out themselves, for an Alumni Board ask or one newsletter. It never gates the public show action, or a shared link would 404. active still governs reachability in both cases.
OPPORTUNITIES_ENABLED defaults to on when unset, so it can never dark the feature by omission. Both flow through Opportunity.publicly_available. There is no Cp::FeatureFlag here — that closed set gates belmontalum.com surfaces, which this phase does not touch.
Known signups’ opportunity views and submissions record ChampionSignupEvents (opportunity_viewed / opportunity_submitted, metadata opportunity_slug) — the signup side’s own event stream, not Cp::ActivityEvent. Anonymous traffic records nothing: it has no signup to hang an event on, and the response row is its record. Anonymous view analytics are deferred.
Cp:: Opportunities surface — the models are namespace-neutral, so this becomes a nullable champion_id column plus a second controller on the same tables.listed is the flag that would make one safe to build).send_receipt_email toggle..ics) on the thank-you page and the receipt email — the natural follow-on now that starts_at exists.