alumni_lookup

Phase 21.7 — Checkbox Fields, Link-Only Visibility, Response Notifications

Status: In progress Planned: 2026-08-05 Surface: Signup ecosystem — shared Opportunity/OpportunityResponse models, app/controllers/signup/, app/controllers/signup_admin/, shared partials in app/views/opportunities/. No held paths touched.

Model-handoff exception (CLAUDE.md §Model Handoff Workflow). Planning and implementation were both done on Opus at the user’s explicit direction (“confirmed scope. continue with opus”), rather than handing off to a lighter implementation model. Noted here because the workflow requires it.


1. Why

Three requests from staff running live opportunity forms, each hitting a different edge of the 21.1–21.5 MVP:

  1. Questions can only take one answer. Every real “which of these apply to you?” question has to be faked as a dropdown that loses information, or as a long-text field nobody can filter on.
  2. Every live opportunity is on the profile hub. There is no way to run a form for just the Alumni Board, or one that only exists inside a newsletter. Activating a form is the only way to make its link work, and activating it also advertises it to everyone.
  3. Responses only notify staff who have a Lookup Portal account. In-app and push notifications cannot reach the person who actually owns a given opportunity — a board liaison, an event organizer, a colleague in another department.

2. Decisions (Planning Interview, 2026-08-05)

# Question Decision Why
1 Where does the notification recipient list live? Per-opportunity only — a notify_emails column. No global default. The use case is per-form by nature (board vs. newsletter vs. general). A global default would also mean a widely-shared link mails somebody by default, which is exactly the risk 21.4 wrote the no-email rule to avoid.
2 How much alumni data goes in the email? Signup record + full Alumni/degree lookup. Staff triaging a response should not have to open the portal to answer “who is this?”. Degrees, college, and affinities are the part that is not on the signup record.
3 How is an email-matched (unproven) champion record presented? Included, explicitly labeled unconfirmed. OpportunityResponse already separates VERIFIED from EMAIL_MATCH; the email must carry that distinction or a guess reads as a confirmed identity. Staff email is not a public surface, so the enumeration rule that governs the thank-you page does not apply here.
4 Where else should an unlisted opportunity be hidden? Profile hub only — that is the only alum-facing surface that lists opportunities today. No cross-promotion surface exists to scrub.

Deliberate reversal — 21.4’s “no email” rule

NotifyOpportunityResponseJob was written with an explicit rule: “In-app + web push only … No email: an opportunity link can be shared widely, and a burst of responses must never flood the admin inbox.”

That protection is preserved, not deleted, by making email per-opportunity opt-in: notify_emails blank — the default and the state of every existing row — behaves exactly as it does today. A form only mails when a human deliberately typed addresses into it, which means the flood risk is scoped to forms somebody is actively watching. The job’s comment block is rewritten to record this rather than left contradicting the code.


3. Scope

3a. Checkbox field type

Adds "checkbox" to Opportunity::FIELD_TYPES, the sixth type. Per the §2c scope fence, a new field type is a code change by design — this is the intended way through that fence, and it closes the multi-select half of BACKLOG “New field types beyond the five.”

The sharp edge: OpportunityResponse#validate_answer blank-checks with value.to_s.strip.blank?. For an empty array that is "[]"not blank — so a required checkbox with nothing ticked would have silently passed validation and stored an empty answer. Array handling is explicit, and the failing test for it was written first.

Adds opportunities.listed (boolean, default: true, null: false).

  active listed Result
Listed & live true true On the profile hub, direct link works
Link only true false Not on the hub, direct link works
Off false either 404 everywhere — active still governs

3c. Per-opportunity response notification email

Adds opportunities.notify_emails (text, comma-separated).

Email contents:

Section Source Shown when
Form answers response.answered_fields Always (retired questions included)
Contact block OpportunityResponse columns Always
Match confidence signup_verified? / signup_email_match? When linked
Signup record — grad year, location/district, affinities, industry, job title, interests ChampionSignup When linked
Alumni record — degrees (degree_code + major_desc + college), BUID, BQID Alumni by buid When linked and BUID matches

4. Files

Migrations

Models

Views / front end

Mail

Controllers

Jobs / export


5. Activity Event Tracking

No new events. opportunity_viewed and opportunity_submitted already cover the alum-facing actions and are unaffected — an unlisted opportunity records the same events as a listed one. The CMS changes are staff-only admin screens, which the phase-plan skill explicitly calls out as having nothing to record.


6. What Was Implemented

Shipped 2026-08-05. bin/test: 5488 runs, 0 failures, 0 errors (3 pre-existing skips).

Bug found and fixed during 21.7 (2026-08-05)

A required checkbox with nothing ticked passed validation. OpportunityResponse#validate_answer blank-checked every answer with value.to_s.strip.blank?. For an empty array that is "[]" — a two-character string, not blank — so the required branch never fired and the response saved with an empty answer. Caught before shipping: the spec named it as the predicted sharp edge, the failing test was written first, and it failed exactly as described.

Why it would not have been caught otherwise: every answer value in the system had been a string since 21.1, so to_s was a safe no-op everywhere it was used and nothing in the existing suite could exercise the difference. The fix is answer_blank?, which branches on Array before falling back to the string check.

Searched for the same pattern across the other answers consumers before fixing — answered_fields, the staff detail view, Csv::OpportunityResponseExporter. None had a blank check, but all three would have rendered ["a", "b"] literally; each now routes through OpportunityResponse.format_answer. Logged as a gotcha in /debug and .github/copilot-instructions.md.

Fixes after first staging look (2026-08-05)

1. Long questions pushed the answers off the page. The answers block reused the two-column table the contact and champion sections use, whose label cell carries white-space: nowrap. That is right for Email / Graduation year / BQID and wrong for a question, which is a staff-authored sentence — one 90-character question pinned the label column wider than the 600px email container and every answer rendered off screen. Not a letter_opener artifact: nowrap is honored by every major email client and is worse on a phone.

Fixed structurally rather than by tuning CSS — the answers block is now stacked (question above answer), which also lets white-space: pre-line preserve the paragraph breaks in an essay answer that the table cell had been flattening. word-break: break-word added to the remaining value cells so a long address can’t do the same thing.

Why it wasn’t caught: the mailer tests asserted answers were present, which passes regardless of where on the page they land, and every fixture used a short question (“Why Belmont?”). The regression test asserts the layout property — no nowrap inside the answers section — because a content assertion structurally cannot see this bug.

2. The staff-queue link was shown to people who can’t use it. The recipient list is explicitly allowed to include people with no Lookup Portal account — that is the whole reason the feature exists — and they were being sent to a login wall.

One message can’t be tailored per recipient (they are bcc’d), so delivery now splits by audience: Opportunity#notify_email_groups partitions the list into portal users and everyone else, and the job sends one email per non-empty group. A single-audience list — the common case — is still a single email. The link is the only difference between the two variants; the email already carries everything needed to act on the response.

OpportunityMailer#response_notification defaults staff_links: to nil, meaning derive it from the recipients (Opportunity.all_portal_users?), so a caller that passes nothing still cannot leak the link to someone who can’t use it. The first cut defaulted it to true and made the correct behavior depend on going through the job, which is a footgun the direct-call test caught.

3. “What we already know” trimmed, and a printable version added. Staff feedback on the first real emails: the champion section carried more than anyone acts on. Location and district duplicated the contact block directly above it — and could disagree with it, since one is what the alum just typed and the other is the address on file. BUID, BQID and the signup date are reconciliation data, not “who is this person” data. All five removed; the CSV export still carries both ids per the project data rule, which is the right home for them.

Alongside it, GET /signup_admin/opportunity_responses/:id/print — a print-styled page, staff save it as a PDF from the browser and send it on. No PDF library was added. The options were a print stylesheet (zero dependencies), Prawn (a second rendering path in a DSL, guaranteed to drift from the email), or wicked_pdf (a ~50MB unmaintained binary, in an app with documented Heroku R14/R15 memory failures). The print stylesheet reuses the same Tailwind as the rest of the app and cannot drift into a different look.

It reads through OpportunityResponseDigest rather than archiving what was emailed, so a response linked to a Champion after the fact prints with today’s record — the explicit requirement. It renders in a new layouts/print (no admin chrome), carries the unconfirmed-match caveat so it survives forwarding, is stamped with the print date, and does not mark the response reviewed.

Spec deviations

Two, both small and both additive:

  1. _status_badge gained a second badge rather than the show/index pages getting a separate one. Four call sites already rendered it; one partial keeps “live” and “listed” presented together everywhere instead of in three-and-a-half places.
  2. duplicate_for_editing carries listed and notify_emails. Not in the plan. They are configuration rather than identity or history, and staff duplicating a board-only form almost always want another board-only form routed to the same people. active: false still gates the copy.

Numbering correction

Planned and initially built as 21.6. That number was already spent on the 2026-07-27 post-review “Thank-you + hub polish” — recorded in the phase README and the launch guide, but never given a roadmap_controller entry, which is why it did not surface in the initial scan. Renumbered to 21.7 across code comments, tests, specs and docs before commit.


7. Deferred

Item Why
Public opportunity index page Still deferred (BACKLOG). listed is the flag that would make such an index safe to build, but staff still prefer targeted links.
Other new field types (file upload, number, …) The §2c fence holds. Checkbox was the one with a concrete use case.
Global/default notification recipient list Rejected in interview — see §2 decision 1.
Notification batching Unchanged from 21.4. The opt-in model narrows the flood risk enough that batching stays trigger-based.