Version: 1.0 Last Updated: July 2026 (Phase 22.1–22.2) Purpose: In-app notification inbox and navbar bell for Lookup Portal staff
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.
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.
| 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 |
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.
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.
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.
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)
Four steps, in order. The test suite enforces steps 1–2.
StaffNotification::NOTIFICATION_TYPES.StaffNotification::SURFACES entry. Skipping this fails
test/models/staff_notification_test.rb — that’s the whole point of the map.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.
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.