alumni_lookup

Alumni Opportunities

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


What it is

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.

Why not a Google Form

  1. A returning signup gets a pre-filled, zero-friction form.
  2. There’s a built-in conversion loop — an anonymous form-filler can become a signup with their data carried forward.
  3. Responses land in staff tools with district lookup and BUID/BQID linking, not a disconnected spreadsheet.
  4. The experience is owned and on-brand.

What it is not


The pieces

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


Authoring an opportunity

  1. Signup Admin → Opportunities → New Opportunity.
  2. Title, then the event details — When, Where, The ask (21.8) — then the three markdown blocks: intro_md (above the form), thankyou_md (after submitting), next_steps_md (“what happens next”).
  3. 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
  4. Preview renders the real public form off the real partials — disabled, unsubmittable, and it creates nothing. What you approve there is what an alum gets.
  5. Go live flips active. Instant, no deploy. Turn off 404s the link immediately and keeps every response already collected.
  6. Duplicate deep-copies an opportunity — inactive, fresh link, regenerated question keys, no responses — because most new opportunities are variations of an existing one.

The editing rules, and why they exist

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:

Delete is offered only while an opportunity has zero responses. Past that, deactivate.


Proof vs. a guess: the two ways a response gets linked

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

The anonymous boundary (the rule that governs the public side)

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.


Spam protection

Two layers, mirroring the signup flow:


Event details — When / Where / The ask (21.8)

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.

The venue/address convention (21.9)

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.

One partial, three variants (21.9)

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

A date closes the form

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:

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.

Upcoming vs. ongoing (21.9)

The hub splits the list on dated?:

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.

The confirmation email (opt-in)

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.


The thank-you page

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 conversion loop

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:

  1. The Phase 19 signup form arrives pre-filled from to_signup_attributes.
  2. The token rides through the POST in a hidden field.
  3. On signup creation, 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.


The staff queue

Signup Admin → Opportunities → Responses.

Notifications

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.


Export

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.


Availability control

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.


Activity tracking

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.


Deferred