Status: ✅ Complete — 22.1–22.5 complete July 28, 2026 Effort Class: Medium Prerequisites: Phase 21 Complete Surface: alumnichampions.com signup ecosystem + Lookup Portal shared shell Next phase: Phase 23 — Naming Unwind (deliberately sequenced after this)
Related Documents:
- ../README.md — Phase Index
- ../../BACKLOG.md — Deferred items
- ../../development/DESIGN-GUIDELINES.md — Visual standards
Two related problems, both caused by the signup ecosystem growing fast while the staff-facing plumbing stayed pointed at the Alumni Network:
| Part | Problem | Deliverable |
|---|---|---|
| A. Notifications | Signup-ecosystem events are recorded but invisible, and the navbar bell shows unclearable aggregate counts pointed mostly at a held surface | A real per-user inbox with a receive → clear → gone flow |
| B. CRM feedback loop | Alumni update their info through signups and none of it reaches Advancement Services | Signup submissions log CrmDataChange rows and flow through the existing export pipeline |
StaffNotification is a complete, correct model: belongs_to :user, read_at,
clicked_at, mark_read!, unread scope, for_dropdown, and a NOTIFICATION_TYPES
list that already covers the new work:
NOTIFICATION_TYPES = %w[
support_thread beta_feedback pending_verification
champion_signup champion_signup_return champion_consent_change opportunity_response
].freeze
Six jobs write to it — NotifyChampionSignupAdminsJob, NotifyOpportunityResponseJob,
NotifyChampionSignupReturnJob, NotifyChampionSignupConsentChangeJob,
Cp::NotifyAdminsJob, plus Cp::SupportThreadsController and Cp::FeedbackController.
There is no controller, no route, and no view for it. Confirmed:
grep -rn "StaffNotification" app/views/ app/controllers/ config/routes.rb
# → only the two Cp:: create! call sites; zero reads, zero UI
Every one of those rows is written to the database and surfaces only as a transient web push. Miss the push and the notification is gone forever.
Meanwhile the navbar bell (app/views/layouts/_action_items.html.erb) renders
ActionItemsService — live aggregate counts, explicitly documented as “not persisted.”
Of its 8 items, 6 link into the held /champions/ surface:
| Item | Path | Surface |
|---|---|---|
| Support Requests | /champions/support_threads |
🛑 Held |
| Champions to Verify | /champions/verifications |
🛑 Held |
| Communities Need Leaders | /champions/communities?needs_leader=true |
🛑 Held |
| Flagged Discussions | /champions/discussions?scope=flagged |
🛑 Held |
| Escalated to Staff | /champions/discussions?scope=escalated |
🛑 Held |
| New Communities | /champions/communities?status=new |
🛑 Held |
| New Champion Signups | /champion_signups |
✅ Active |
| CRM Changes Pending | /settings/affinaquest/crm_changes |
✅ Active |
The single active-surface signup item is a 7-day rolling count:
ChampionSignup.active.where(created_at: 7.days.ago..).count
That is the “bucket of 2 new updates” with no way to clear it — clicking through changes nothing, and it only disappears when the signups age out. Nothing at all represents opportunity responses, returning-visitor updates, or consent changes.
CrmDataChange is mature: pending → exported → verified lifecycle, CrmDataExportBatch,
field protection against Affinaquest import overwrites, and a working staff export UI at
/settings/affinaquest/crm_changes.
champion_signups collects exactly the data Advancement Services wants:
| Category | Columns |
|---|---|
| Contact | email, phone, street, city, state, zip_code, maiden_name |
| Employment | company, job_title, industry |
| Student orgs | affinity_codes (jsonb), affinity_other |
Not one signup writes a CrmDataChange row. The only log_champion_update caller is
Cp::ConfirmationsController — the held portal. So an alum can update their employer and
address through the active product and it dies in the champion_signups table.
⚠️ Gap 1 below is now only half true — revised for multi-employment July 28, 2026. Phase 24 shipped (24.1–24.3, July 28 2026) and was deliberately sequenced ahead of this phase for exactly this reason.
alumnistill has no employment columns and still should not (that part stands), but there is now a CRM-sourcedemploymentstable to compare a signup’s self-reportedcompany/job_titleagainst. This changes §22.4, the decision table row on employment representation, and the acceptance criterion at “Reported employment creates a row with blankold_value”:
- “Already on file” checks every row in
alumni.employments, not justcurrent_employment.employmentsis one-to-many — an alum can hold more than one current position (Phase 24 §4) — so checking only the most-recent row would misflag a signup reporting the alum’s second job as a brand-new employer. Match the normalized reported employer against all of that BUID’s rows.- Suppress the
CrmDataChangeentirely when the reported employer matches any existing row — otherwise every returning signup logs a redundant change request- When it matches none of them, diff against
current_employmentspecifically —old_value = alumni.current_employment&.employer_name,new_value= the reported employer. This asserts a replacement of the most-recent job even though the alum may just be adding a position; it’s a deliberate simplification chosen to keep a real old→new diff (decided during 22.4 planning) rather than falling back to a bare assertion- Keep the blank-
old_valueform only for alumni with zero employment rows — there is nothing to diff against.employer_nameis nullable, socurrent_employment&.employer_namecan be nil even when rows exist; that case also falls through to the blank-old_valueform.
Two structural gaps block a naive fix:
alumni has no employment columns. Only email_business. There is no
company/job_title/industry to diff against, so employment cannot produce a
conventional old→new change. (Superseded in part by Phase 24 — see the note above.)CrmDataChange requires an identifier (has_identifier validates
buid/contact_id/alumni_id presence). Anonymous and unmatched signups have none.Student orgs are the exception — alumni_affinities is keyed (buid, affinity_code) with
a unique index, so a signup’s affinity_codes are genuinely diffable against what’s
on file.
Per CLAUDE.md → Default Work Surface:
champion_signups/) + the shared Lookup Portal navbarStaffNotification, CrmDataChange, ActionItemsService are identity-agnostic shared models/services (app/models/*.rb, app/services/*.rb, no namespace) — active by CLAUDE.md’s shared-model ruleapp/controllers/champion_signups/ with layout "signup_admin"champion_signups/app/**/cp/**, Cp::*, app/controllers/champions/**, app/views/champions/**ChampionSignupEvent, not Cp::ActivityRecorderRevised July 28, 2026 — the notification inbox is
/notifications, not/champion_signups/staff_notifications. Approved before building 22.1. The original checkbox assumed the inbox was signup staff tooling. It isn’t:
- The feed spans both surfaces by design. §22.1 explicitly groups
support_thread/beta_feedback/pending_verificationunder an Alumni Network heading. Filing those at a/champion_signups/URL misrepresents them.- The bell lives in
layouts/_navbar.html.erb, which all five Lookup Portal layouts render (application,tools,settings,champion_admin,signup_admin). Reading a notification from/alumniwould have bounced staff into the Signup Admin sidebar.StaffNotificationis already an identity-agnostic shared model; the controller matches it.Shipped as
NotificationsControllerat/notificationswithlayout "application"./notificationsis free on the Lookup Portal:Cp::NotificationsControllerclaims the same path but sits inside thebelmontalum/alumnichampionshost constraint at the top ofroutes.rb, so it never matches on*.alumnilookup.com. Still zero writes to any held path.
One held-surface read, no write: 22.2 regroups the six /champions/ action items under
an “Alumni Network” heading. It edits ActionItemsService (shared) and
layouts/_action_items.html.erb (shared) — the held paths appear only as link target
strings. No file under champions/ is created or modified.
| Goal | Definition of Done |
|---|---|
| Every staff notification is visible in-app | StaffNotification has a route, controller, index view, and bell feed |
receive → clear → gone works |
Clicking marks read; “Mark all read” clears the group; badge decrements |
| Signup notifications never mix with Alumni Network ones | Feed groups by surface with visible headings |
| Signup contact updates reach Advancement Services | Linked signup submission creates CrmDataChange rows visible at /settings/affinaquest/crm_changes |
| Employment and student org updates are exportable | Both appear in the CRM export CSV with correct source attribution |
| Unmatched signups are visible, not silently dropped | Staff queue flags “needs identity match” |
| Non-Goal | Rationale |
|---|---|
Renaming anything (/champions/, Cp::Champion, “Champion Dashboard”) |
Deliberately deferred to Phase 23 |
Adding employment columns to alumni |
alumni is Belmont-provided source data; self-reported employment must not pollute it |
| Shared/team-wide notification clearing | Per-user is already built and is correct inbox behavior (see §7) |
| Notification batching / digest collapsing | Backlog item; revisit only if volume becomes a problem |
| Auto-export of CRM changes | Export stays a deliberate staff action |
| Building a new export UI | /settings/affinaquest/crm_changes already works; extend it |
Give StaffNotification the UI it never got.
NotificationsController — index, mark_read, mark_all_read (name/path revised — see §3)champion_signup, champion_signup_return, champion_consent_change, opportunity_response) vs Alumni Network (support_thread, beta_feedback, pending_verification)mark_clicked! on click-through — the column exists and is never writtenSURFACES map on the model keyed by notification type, so a new type has exactly one place to declare where it belongsWhat was implemented
| File | Note |
|---|---|
app/models/staff_notification.rb |
SURFACES / SURFACE_ORDER / SURFACE_LABELS, for_surface + by_surface scopes, surface / surface_label / type_label, surface_for / types_for_surface / surface_ordering_sql |
config/routes.rb |
resources :notifications, only: [:index] + mark_read member / mark_all_read collection, in the Lookup Portal section |
app/controllers/notifications_controller.rb |
Every action scopes to current_user.staff_notifications |
app/helpers/notifications_helper.rb |
staff_notifications_scope, unread_staff_notification_count, literal-only Tailwind class maps per surface |
app/views/notifications/index.html.erb |
Stats, filters, surface-grouped list with per-group “Mark all read”, pagination, empty state |
test/models/staff_notification_test.rb (+10), test/controllers/notifications_controller_test.rb (22), test/fixtures/staff_notifications.yml (+3) |
Spec additions (22.1)
by_surface ordering scope. The spec asked for grouped headings and pagination and
didn’t reconcile them. Ordering by recency alone repeats both headings on every page, so the
list is surface-major with recency inside. The CASE is generated from SURFACE_ORDER, so
adding a surface reorders the inbox without touching the SQL.NOTIFICATION_TYPE has a SURFACES entry (plus every surface
has a label and appears in SURFACE_ORDER). This is what actually makes the map “exactly one
place to declare where it belongs” — without it, a new type silently lands in an unlabeled group.mark_read sets clicked_at; mark_all_read deliberately does not. The spec named
mark_clicked! without saying which action owns it. Distinguishing “I went and looked” from
“I cleared the batch” is the only thing that gives clicked_at meaning, and there’s a test.current_user.staff_notifications.find), not in the
view — a bare StaffNotification.find would let user A clear user B’s inbox. Tested.Rebuild layouts/_action_items.html.erb as one dropdown with two clearly separated sections:
| Section | Source | Behavior |
|---|---|---|
| New | StaffNotification.unread.for_dropdown |
Clearable. Per-item click marks read; “Mark all read” clears the group |
| Pending work | ActionItemsService |
Standing queues. Relabeled to read as counts that auto-resolve, not as unread items |
What was implemented
| File | Note |
|---|---|
app/views/layouts/_action_items.html.erb |
Rewritten. #bell-new-notifications / #bell-pending-work ids are load-bearing for tests |
app/services/action_items_service.rb |
surface: on the ActionItem struct, SURFACE_ORDER / SURFACE_LABELS, items_by_surface, surface_label |
test/views/action_items_bell_test.rb (14), test/services/action_items_service_test.rb (+7) |
Spec additions (22.2)
:lookup. The spec named two. crm_pending points at
/settings/affinaquest/crm_changes, which is neither signup nor Alumni Network, and lumping it
into either would be a lie. SURFACE_ORDER is [signup, lookup, alumni_network] — the held
surface sorts last. The two shared symbols are aliased from StaffNotification so the bell
labels one surface one way across both of its sections; a test asserts they can’t drift.path starts with
/champions/ must be filed under Alumni Network and vice versa. Prevents an item being
visually de-emphasized while linking somewhere active, or the reverse.Spec deviations (22.2)
priority_count was deleted, not just bypassed. The spec said the badge should count
unread notifications; it didn’t say what becomes of the method that used to feed it. Its only
caller was the badge, and every number it summed (support requests, champion verifications,
moderation queues) pointed at the held surface — leaving it would be an orphan by the patterns
skill’s anti-orphan rule. Its private helper discussions_needing_moderation_count went with
it. No other caller existed.Settings::DistrictsControllerTest#test_should_search_by_name three directories away. The
fixture file now carries a warning comment and neutral bodies. Separately,
NotifyOpportunityResponseJobTest asserted a global StaffNotification count of zero where it
meant “the job wrote nothing”; changed to assert_no_difference.Wire the existing pipeline to the signup flow.
change_source: champion_signup (existing champion_portal is the held Cp:: flow and must stay distinct — Advancement Services needs to know which product the alum used)ChampionSignupCrmLogger — takes a ChampionSignup, resolves the linked Alumni via buid, diffs contact fields, logs only meaningful changesCrmDataChange.log_bulk_changes; extend PROTECTABLE_FIELDS where a signup-reported value should block an Affinaquest overwritebuid → no rows (see 22.5)champion_signups → alumni) is not 1:1 and must be explicit: zip_code → zip, street → (no target column — see deviation), maiden_name → maiden_namesource_table value employment. Match the signup’s reported employer (normalized) against every row in alumni.employments.where(buid:), not just current_employment:
CrmDataChange logged, nothing new to reportjob_title diff against the position that matched; identical title (or no title reported) still logs nothingold_value = alumni.current_employment&.employer_name, new_value = reported employerold_value blank and new_value set — semantically “the alum reports they now work at X,” not a diff. summary already renders (blank) → 'value' correctly for this casesignup.affinity_codes against alumni_affinities.where(buid:). Additions log against source_table: "affinities" (already a valid value). Removals are not logged — absence from a signup form is not an assertion that someone left an organizationaffinity_other (free text) logs as a note for staff review, never as a code — it cannot be validated against the affinities tablebuid surface in the signup staff queue flagged “needs identity match”assign_alumni action) retroactively runs the CRM logger — the held update is not lost/settings/affinaquest/crm_changes export CSV with the new source and source_table valuessend_data, data: { turbo: false } on the link, and both tests (endpoint + link markup)What was implemented (22.3–22.5)
| File | Note |
|---|---|
db/migrate/20260728160000_add_champion_signup_id_to_crm_data_changes.rb |
Nullable FK mirroring cp_champion_id |
app/models/crm_data_change.rb |
champion_signup source, employment source table, street in PROTECTABLE_FIELDS, new PROTECTING_SOURCES constant, belongs_to :champion_signup, from_champion_signup, app_originated derived from SOURCES, log_bulk_changes accepts pairs + notes + champion_signup, label/badge entries |
app/services/champion_signup_crm_logger.rb |
The whole diffing contract — contact, employment, student orgs |
app/models/champion_signup.rb |
CRM_REPORTABLE_FIELDS, needs_identity_match scope, crm_reportable_fields / held_crm_update_count / needs_identity_match?, has_many :crm_data_changes, dependent: :nullify |
app/controllers/public/champion_signups_controller.rb |
log_crm_changes called from all three save points (who_you_are, affinities, where_you_are) |
app/controllers/champion_signups_controller.rb |
assign_alumni runs the logger retroactively and reports the count; needs_identity_match stat + list filter |
app/services/csv/crm_data_change_exporter.rb |
Extracted from the controller; adds Source Table + Champion Signup ID, resolves contact_id in one grouped query |
app/controllers/settings/affinaquest_controller.rb |
export_crm_changes reduced to send_data + exporter |
app/views/settings/affinaquest/crm_changes.html.erb |
Source-table subtitle, signup back-link |
app/views/champion_signups/{index,all,show}.html.erb |
Dashboard callout, list badges, filter toggle, pre-link explainer |
test/services/champion_signup_crm_logger_test.rb (27), test/services/csv/crm_data_change_exporter_test.rb (7), test/models/crm_data_change_test.rb (+7), test/models/champion_signup_test.rb (+6), test/controllers/champion_signups_controller_test.rb (+6), test/controllers/settings/affinaquest_controller_test.rb (+5), test/controllers/public/champion_signups_controller_test.rb (+5) |
Spec additions (22.3–22.5)
app_originated is now derived from SOURCES and has a guard test. It was a hardcoded %w[champion_portal staff_edit manual], and it is the scope both the staff CRM page and the export CSV are built on. Adding champion_signup to SOURCES while forgetting this list would have produced the exact failure mode this phase exists to fix: rows written correctly and never reaching Advancement Services, with nothing visibly broken. The test asserts every non-import source is in the scope.PROTECTING_SOURCES constant. The %w[staff_edit champion_portal] list was inlined in both field_protected? and preload_protections; a new source added to one and not the other protects a field on the single-record path but not the bulk-import path. Extracted, with a test that it stays a subset of the app-originated sources.already_logged?). The spec said “logs only meaningful changes” without saying what happens when the profile hub’s Save is pressed twice, which the hub-and-spoke flow makes routine. Same BUID + source table + field + value with a non-skipped status suppresses. skipped deliberately does not — a dismissed change that the alum re-reports is new information.needs_identity_match is narrower than the existing not_linked filter. Unlinked and actually holding data the export is waiting on. CRM_REPORTABLE_FIELDS on the model is the shared definition, with a test asserting it matches the logger’s field coverage — a field the logger sends but the queue ignores is a signup nobody knows to match.alumni.all_emails suppresses. alumni carries five email columns; diffing only against email would log a “change” for an address already on file under email_business.[].Spec deviations (22.3–22.5)
street has no alumni column to map to. §22.3 specified street → “address field”. alumni has no street or address column — only city, state, zip (verified against db/schema.rb). Rather than drop the field, it logs with a blank old_value under field_name: "street", the same shape §22.4 defines for an alum with zero employment rows, and is added to PROTECTABLE_FIELDS so it still blocks an import overwrite. This is the second place in the phase where “we have no column to diff against” resolves to a bare assertion rather than a drop.job_title diff against the position that matched, not current_employment: an alum promoted at their older employer must not have their new title diffed against their newer job’s. Identical title, or no title reported, still logs nothing. industry remains notes-only — employments has no industry column to diff against, so it can only ride along on a row that does.Settings::AffinaquestController with an Alumni.find_by per row. Extracted to Csv::CrmDataChangeExporter with a grouped contact_id lookup. The link already carried data: { turbo: false }; both required tests were missing and are now present.Source Table was not requested but became load-bearing: field_name stopped identifying the target once employer_name and affinity_code joined alumni, so several rows can now share a BUID and a field name and only the table says what to update.ChampionSignupEvent type. §10 asks for event assertions “for any newly tracked signup-side action”. Nothing new is tracked on the signup side — the logger hangs off the existing section_saved / assign_alumni paths, so adding an event type would have duplicated a record we already keep.No new tables. Three constant/column changes:
| Change | Where | Migration? |
|---|---|---|
SOURCES += champion_signup |
CrmDataChange |
No — validation constant |
SOURCE_TABLES += employment |
CrmDataChange |
No — validation constant |
PROTECTABLE_FIELDS += signup-reported fields |
CrmDataChange |
No — validation constant |
champion_signup_id FK |
crm_data_changes |
Yes — mirrors existing cp_champion_id, nullable |
The champion_signup_id column is what makes a change traceable back to the submission
that produced it, exactly as cp_champion_id does for the portal.
| Question | Decision | Rationale |
|---|---|---|
| Bell architecture | One bell, two sections | The user needs both a clearable feed and real standing counts; they are different data with different semantics and must not be merged into one list |
| Notification clear scope | Per-user (unchanged) | Already built; a colleague clearing theirs must not hide an item from you |
| Employment representation | CrmDataChange; blank old_value only when no employment record exists; real diff against current_employment when the reported employer isn’t already on file |
alumni has no employment columns and must stay clean as Belmont-provided source data. Once employments exists (Phase 24), a genuine old→new diff is possible and is more actionable than a bare assertion |
| Employment match scope (multi-job alumni) | Check all of alumni.employments, not just current_employment, before deciding “already on file”; if nothing matches, diff against current_employment specifically |
employments is one-to-many (Phase 24 §4); checking only the most-recent row would misflag an alum’s second job as a new employer. Diffing against the most-recent row when nothing matches keeps the old→new form instead of a bare assertion, at the cost of implying a replacement that may really be an addition |
| Unlinked signups | Hold until linked | A CRM update Advancement Services cannot attach to a constituent is unusable; surfacing it as “needs identity match” makes the matching work visible instead of silently dropping data |
| Student org removals | Not logged | Absence from a form is not an assertion of departure |
| Naming/rename work | Deferred to Phase 23 | Explicit user decision on sequencing |
| Item | Original Phase | Status |
|---|---|---|
Affinity export mechanism (→ crm_data_changes) |
Phase 1.4 | Pulled into 22.4 — was “awaiting Affinaquest export format”; the Lookup-side alumni_affinities table makes this tractable now without waiting on the portal’s cp_affinities |
| Track affinity adds/removes for Advancement Services | Phase 1.4 | Partially pulled into 22.4 — adds only; removals stay deferred by design |
| Item | Why not now |
|---|---|
| Staff notification batching (“N new responses per opportunity per hour”) | Trigger condition (a shared link flooding the queue) hasn’t happened; 22.1 grouping may be sufficient |
| CRM Workflow Improvements 1.7 — move CRM changes to a dedicated section | Proposes /champions/crm_changes, a held path. Revisit after Phase 23 settles naming |
| Auto-export on verification | Export should stay a deliberate staff action |
StaffNotification has a route, controller, and index view; grep -rn "StaffNotification" app/views/ returns hitsclicked_at; badge decrementsStaffNotification count — not action-item totalsCrmDataChange rows with change_source: "champion_signup"employments row (any row, not just current_employment) creates zero employer_name rows — and, per the July 28 revision, a job_title row when the title at that position changedold_value: alumni.current_employment&.employer_name, new_value set, and source_table: "employment"employments rows creates a row with blank old_value and source_table: "employment"affinities row; an org already in alumni_affinities creates noneCrmDataChange rows and appears flagged “needs identity match”/settings/affinaquest/crm_changes export CSVbin/test — 0 failures, 0 errors (5,168 runs / 14,535 assertions after 22.5)StaffNotificationsController actions, including cross-user isolation: user A cannot mark user B’s notification readChampionSignupCrmLogger service tests: linked vs unlinked, no-change (logs nothing), partial change, employment match-suppresses (single- and multi-employment alumni), employment diff-against-current_employment (no match, multi-employment), employment blank-old_value (zero employment rows), affinity add vs existingassign_alumniChampionSignupEvent assertions for any newly tracked signup-side actiondocs/features/CHAMPION_SIGNUP_SYSTEM.md — CRM feedback loop section (22.3–22.5)docs/features/STAFF_NOTIFICATIONS.md — types, surface map, clear semantics (22.1–22.2)docs/planning/phases/README.md — index is stale (stops at Phase 18 while the roadmap has 21 complete); bring it currentapp/controllers/champions/roadmap_controller.rb — add Phase 22 (pre-approved exception per CLAUDE.md rule 5)CHANGELOG.mdBACKLOG.md — mark the two affinity items resolved