alumni_lookup

Staff Notifications

Version: 1.0 Last Updated: July 2026 (Phase 22.1–22.2) Purpose: In-app notification inbox and navbar bell for Lookup Portal staff


Table of Contents

  1. Overview
  2. Surfaces
  3. Notification Types
  4. Clear Semantics
  5. The Inbox
  6. The Bell
  7. Writing a Notification
  8. Adding a New Type
  9. Gotchas

Overview

StaffNotification is the per-user, persistent notification record for Lookup Portal staff (User). It is not the alum-facing Cp::Notification, which belongs to the held belmontalum.com portal.

Piece Location
Model app/models/staff_notification.rb
Controller app/controllers/notifications_controller.rb
Inbox view app/views/notifications/index.html.erb
Helper app/helpers/notifications_helper.rb
Bell app/views/layouts/_action_items.html.erb
Standing queues app/services/action_items_service.rb

The model shipped in Phase 1 with jobs writing to it and no way to read it — rows landed in the database and surfaced only as a transient web push. Phase 22.1 added the inbox; 22.2 rebuilt the bell around it.


Surfaces

StaffNotification::SURFACES maps every notification type to the product it came from. It is the single place a type declares where it belongs — the inbox headings, the bell sections and the filter dropdown all read it, and test/models/staff_notification_test.rb fails the build if a type is added without an entry.

Surface Constant Label Status
Signup ecosystem StaffNotification::SIGNUP “Champion Signup” ✅ Active
belmontalum.com portal StaffNotification::ALUMNI_NETWORK “Alumni Network” 🛑 On hold

SURFACE_ORDER puts the active surface first everywhere the two appear together.

ActionItemsService uses the same two symbols (aliased, with a test asserting they can’t drift) plus a third, :lookup, for queues that are neither — currently only CRM changes pending export. A test asserts that an action item pointing at /alumni_network/ is always filed under Alumni Network and never the reverse.

Labels are Phase 22 names. Renaming them is Phase 23’s job — don’t rename them here.


Notification Types

Type Surface Written by
champion_signup Champion Signup NotifyChampionSignupAdminsJob
champion_signup_return Champion Signup NotifyChampionSignupReturnJob
champion_consent_change Champion Signup NotifyChampionSignupConsentChangeJob
opportunity_response Champion Signup NotifyOpportunityResponseJob
support_thread Alumni Network Cp::SupportThreadsController, StaffNotification.notify_support_responders
beta_feedback Alumni Network Cp::FeedbackController
pending_verification Alumni Network Cp::NotifyAdminsJob

Clear Semantics

Two timestamps, and the difference between them is load-bearing:

Column Set by Means
read_at mark_read and mark_all_read You’ve seen it
clicked_at mark_read only You went and looked at the thing

mark_all_read is a bulk dismiss and must never claim clicked_at — otherwise the column can’t tell “I followed up” from “I cleared my inbox”.

Clearing is per-user, enforced at the lookup. Every controller action scopes to current_user.staff_notifications, so a colleague clearing theirs can’t hide an item from you, and can’t be probed for one either. A bare StaffNotification.find in set_notification would let user A clear user B’s inbox — there is a regression test.


The Inbox

GET /notifications — unnamespaced, layout "application".

It is not under /champion_signups/ even though most traffic is signup-driven: the feed spans both surfaces by design, and the bell that opens it renders in layouts/_navbar, which all five Lookup Portal layouts include.

/notifications is safe to claim here because Cp::NotificationsController owns the same path inside the belmontalum / alumnichampions host constraint at the top of routes.rb, so it never matches on *.alumnilookup.com.

Route Action
GET /notifications Filter by surface, type, read state; paginated
PATCH /notifications/:id/mark_read The click-through: marks read + clicked, forwards to notification.url
PATCH /notifications/mark_all_read Clears everything, or one surface with ?surface=signup

Ordering is surface-major, recency within (by_surface.recent). Sorting by recency alone would repeat both group headings on every page. The CASE is generated from SURFACE_ORDER, so adding a surface reorders the inbox without touching SQL.


The Bell

app/views/layouts/_action_items.html.erb — one dropdown, two sections that must not be merged:

Section Source Semantics
New current_user.staff_notifications.unread.for_dropdown A feed you clear
Pending work ActionItemsService Live aggregate counts that resolve themselves when the underlying work gets done

The badge counts unread notifications only. Pending work carries its own muted count beside its heading. Before 22.2 the badge summed ActionItemsService priority items — every one of them a standing queue on the held surface that nobody could clear from the bell, so the number staff saw was never the number they could act on.

Alumni Network action items collapse under one de-emphasized heading while that surface is on hold. #bell-new-notifications and #bell-pending-work are load-bearing ids: tests scope into them, since the bell renders on every page including /notifications itself.


Writing a Notification

StaffNotification.create!(
  user: user,
  notification_type: "champion_signup",   # must be in NOTIFICATION_TYPES
  title: "New Champion Signup",
  body: "A new signup is waiting for review",
  url: "/champion_signups/#{signup.id}",  # optional; mark_read forwards here
  notifiable: signup                       # optional polymorphic ref
)

after_create_commit fans out a web push to the user’s subscribed devices when WebPushService.configured?. The database row is the durable copy — the push is best-effort.

For support threads there’s a fan-out helper:

StaffNotification.notify_support_responders(title:, body:, url:, notifiable: nil)

Adding a New Type

Four steps, in order. The test suite enforces steps 1–2.

  1. Add the string to StaffNotification::NOTIFICATION_TYPES.
  2. Add a StaffNotification::SURFACES entry. Skipping this fails test/models/staff_notification_test.rb — that’s the whole point of the map.
  3. Write the notification from wherever the event happens (a job, usually).
  4. Nothing in the inbox, the bell, or the filter dropdown needs touching. They all read the constants.

If the new type belongs to a surface that doesn’t exist yet, add it to SURFACE_ORDER and SURFACE_LABELS too — both have their own guard tests.


Gotchas

Fixture bodies show up on every page. The bell renders unread notification bodies site-wide, so a staff_notifications.yml body containing a district name, an alum name, or anything another test asserts the absence of will fail that test from three directories away. This actually happened in 22.2 — a fixture body reading “Chris Vega (Atlanta) just signed up” broke Settings::DistrictsControllerTest#test_should_search_by_name, which asserts /Atlanta/ is absent from the districts page. Keep fixture bodies generic.

Don’t assert global StaffNotification counts in tests. Fixtures carry rows of several types. Use assert_difference / assert_no_difference, not assert_equal 0, ...count.

Assertions about the inbox must scope to #notification-list. The bell renders the same notifications on that page, so a bare response.body match can’t tell the inbox from the bell.

The url is followed on click. mark_read redirects to notification.url with allow_other_host: false. Write app-relative paths, never absolute external URLs.