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.
Three requests from staff running live opportunity forms, each hitting a different edge of the 21.1–21.5 MVP:
| # | 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. |
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.
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.”
options array, so the CMS options editor and the
“needs at least one option” validation extend to cover both select and
checkbox.answers has to be array-aware:
validation, the staff detail view, the CSV export, and the new email.select, so a hand-crafted POST cannot smuggle in a value.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 |
listed is a display flag, not an access flag. It never gates
Signup::OpportunitiesController#set_opportunity, so a shared link keeps
working; Opportunity.publicly_available is unchanged.listed: true, so the deploy changes nothing..listed; that is the only behavioral change.Adds opportunities.notify_emails (text, comma-separated).
OpportunityMailer#response_notification, root-level to match
ChampionSignupMailer and the unnamespaced model it serves.OpportunityResponseDigest assembles the email’s content
so the mailer stays thin and the assembly is unit-testable without rendering.NotifyOpportunityResponseJob, not a second job,
so in-app + push + email stay one code path with one retry story.reply_to is the submitter’s address — the most common next action on one of
these emails is replying to the person.lookup_url_options,
matching ChampionSignupMailer#admin_notification.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 |
Migrations
add_listed_to_opportunities — boolean, default true, null falseadd_notify_emails_to_opportunities — text, nullableModels
app/models/opportunity.rb — checkbox type, options validation, listed
scope, notify_email_list, recipient format validationapp/models/opportunity_response.rb — array-aware validate_answer,
format_answer shared formatterViews / front end
app/views/opportunities/fields/_checkbox.html.erb (new)app/views/signup_admin/opportunities/_field_row.html.erb — type optionapp/views/signup_admin/opportunities/_form.html.erb — listed toggle,
notify_emails inputapp/javascript/controllers/field_rows_controller.js — options editor shows
for checkbox tooapp/views/signup_admin/opportunity_responses/show.html.erb — array answersapp/views/signup_admin/opportunities/{index,show}.html.erb — link-only badgeapp/mailers/opportunity_mailer.rb (new)app/views/opportunity_mailer/response_notification.{html,text}.erb (new)app/services/opportunity_response_digest.rb (new, shared layer)Controllers
app/controllers/signup/signups_controller.rb — .listed on the hub queryapp/controllers/signup_admin/opportunities_controller.rb — permit
:listed, :notify_emailsapp/controllers/signup/opportunities_controller.rb — array-safe answer paramsJobs / export
app/jobs/notify_opportunity_response_job.rb — email delivery + rewritten
rationale commentapp/services/csv/opportunity_response_exporter.rb — array answersNo 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.
Shipped 2026-08-05. bin/test: 5488 runs, 0 failures, 0 errors (3 pre-existing skips).
Checkbox field type — FIELD_TYPES gains checkbox; OPTION_TYPES and
MULTI_VALUE_TYPES added so the option-list rules and the array rules are each
named once rather than inlined per call site. New fields/_checkbox partial posts
answers[key][] with a hidden "" so the key is present even when nothing is
ticked. OpportunityResponse.answer_values / .format_answer / .answer_blank?
live on the model that owns answers, because validation, the staff view, the CSV
and the email all need identical semantics. normalize_answers strips the
transport placeholder before it can reach the database. Strong params needed no
change — permit(answers: {}) already passes arrays of scalars (verified, then
recorded in a comment because it is not obvious).
Link-only visibility — opportunities.listed, scope :listed, and one
.listed on the hub query. set_opportunity deliberately untouched. _status_badge
became two badges with listed as an optional local defaulting to true.
Per-opportunity notification email — opportunities.notify_emails with
tolerant parsing (commas, semicolons, newlines), normalization on save, and
per-address format validation so a typo fails at authoring time instead of bouncing
silently. OpportunityMailer#response_notification (bcc, reply-to the submitter),
OpportunityResponseDigest for content, both delivered from the existing
NotifyOpportunityResponseJob with the two halves independently rescued.
Tests — +8 model (checkbox validation, format_answer), +12 Opportunity
(types, listed, recipient parsing/validation, duplicate), +17 digest, +12 mailer,
+5 job, +11 public controller, +5 CMS controller, +2 exporter.
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.
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.
Two, both small and both additive:
_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.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.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.
| 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. |