alumni_lookup

Phase 22 — Staff Notification Inbox & Signup CRM Feedback Loop

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:


Table of Contents

  1. Overview
  2. Why This Phase Exists
  3. Surface Check
  4. Goals & Non-Goals
  5. Sub-Phase Breakdown
  6. Schema Changes
  7. Decisions Made During Planning
  8. Backlog Items Pulled In
  9. Acceptance Criteria
  10. Testing Requirements
  11. Documentation Updates

1. Overview

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 uncleara­ble 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

2. Why This Phase Exists

Part A — the notification machinery exists and is unreachable

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.

Part B — the CRM pipeline never hears from signups

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. alumni still has no employment columns and still should not (that part stands), but there is now a CRM-sourced employments table to compare a signup’s self-reported company / job_title against. This changes §22.4, the decision table row on employment representation, and the acceptance criterion at “Reported employment creates a row with blank old_value”:

Two structural gaps block a naive fix:

  1. 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.)
  2. 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.


3. Surface Check

Per CLAUDE.md → Default Work Surface:

Revised 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:

Shipped as NotificationsController at /notifications with layout "application". /notifications is free on the Lookup Portal: Cp::NotificationsController claims the same path but sits inside the belmontalum / alumnichampions host constraint at the top of routes.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.


4. Goals & Non-Goals

Goals

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-Goals

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

5. Sub-Phase Breakdown

22.1 — Staff Notification Inbox ✅ Complete

Give StaffNotification the UI it never got.

What 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)

22.2 — Bell Rework (Two Sections) ✅ Complete

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)

Spec deviations (22.2)

22.3 — Signup → CrmDataChange (Contact Info) ✅ Complete

Wire the existing pipeline to the signup flow.

22.4 — Employment & Student Org Capture ✅ Complete

22.5 — Identity-Gated Queue & Export ✅ Complete

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)

Spec deviations (22.3–22.5)


6. Schema Changes

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.


7. Decisions Made During Planning

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

8. Backlog Items Pulled In

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

Reviewed and Left in Backlog

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

9. Acceptance Criteria

10. Testing Requirements

11. Documentation Updates