alumni_lookup

Phase 8: Notifications & Digests Consolidation

Champion Portal Development Phase 8

Purpose: Consolidate and standardize the notification system so Champions receive:

Status: Planning Last Updated: January 2026

Related Documents:


Table of Contents

  1. Discovery: Current Notification Inventory
  2. Target State: Consolidated Digest Taxonomy
  3. Settings UI Consolidation
  4. Template Standardization
  5. Heroku Scheduler Plan
  6. Champion Onboarding Email Series
  7. UI Notes & Gaps
  8. Deliverables & Checklist

1. Discovery: Current Notification Inventory

1.1 Source of Truth

All notification types are defined in app/models/cp/notification.rb:

NOTIFICATION_TYPES = %w[
  new_message
  new_member
  new_event
  community_suggestion
  join_request_received
  join_request_approved
  join_request_denied
  verification_pending
  verification_approved
  support_reply
  community_leader_assigned
  post_reply
  comment_reply
  post_reaction
  comment_reaction
  contact_added
].freeze

1.2 Complete Notification Type Inventory

Type Trigger Default Frequency In Settings UI? Dedicated Mailer?
new_message Champion receives a message Daily ✅ Messages group No (uses MessageNotificationJob)
new_member New member joins a community Daily ✅ Community group No
new_event Event posted to a community Weekly ✅ Community group No
community_suggestion System suggests a community Weekly ✅ Community group No
join_request_received CL receives a join request Daily ✅ Join Requests group No
join_request_approved Champion’s request approved Immediate ✅ Join Requests group ✅ Yes (CommunityMailer)
join_request_denied Champion’s request denied Immediate ✅ Join Requests group ✅ Yes (CommunityMailer)
verification_pending Champion needs verification Immediate ✅ Account group No
verification_approved Champion gets verified Immediate ✅ Account group No
support_reply Staff replies to support thread Immediate ❌ MISSING No
community_leader_assigned Champion becomes a CL Immediate ❌ MISSING No
post_reply Someone comments on your post Daily ❌ MISSING No
comment_reply Someone replies to your comment Daily ❌ MISSING No
post_reaction Someone reacts to your post Daily ❌ MISSING No
comment_reaction Someone reacts to your comment Daily ❌ MISSING No
contact_added Someone adds you as a contact Off ✅ Contacts group No

1.3 Default Email Frequencies (from notification.rb)

DEFAULT_EMAIL_FREQUENCIES = {
  "new_message" => "daily",
  "new_member" => "daily",
  "new_event" => "weekly",
  "community_suggestion" => "weekly",
  "join_request_received" => "daily",
  "join_request_approved" => "immediate",
  "join_request_denied" => "immediate",
  "verification_pending" => "immediate",
  "verification_approved" => "immediate",
  "support_reply" => "immediate",
  "community_leader_assigned" => "immediate",
  "post_reply" => "daily",
  "comment_reply" => "daily",
  "post_reaction" => "daily",
  "comment_reaction" => "daily",
  "contact_added" => "off"
}.freeze

1.4 Current Email Delivery Paths

Path Job/Mailer Trigger Notes
Immediate Messages MessageNotificationJob Runs every 10 min 5-min delay, respects muting, checks if thread read
Immediate Notifications NotificationService#send_immediate_email_if_needed On notification creation Uses NotificationMailer.immediate_notification
Daily Digest Cp::NotificationDigestJob PENDING in scheduler Queries champions with email_frequency: :daily
Weekly Digest Cp::NotificationDigestJob PENDING in scheduler Queries champions with email_frequency: :weekly
Join Approved/Denied Cp::CommunityMailer On join request decision Skips NotificationMailer (TYPES_WITH_DEDICATED_MAILERS)

1.5 Current Settings UI Groups

The Settings page (app/views/cp/settings/_section_notifications.html.erb) shows:

Group Notification Types Notes
Messages new_message ✅ Complete
Contacts contact_added ✅ Complete
Community new_member, new_event, community_suggestion Missing discussion types
Join Requests join_request_received, join_request_approved, join_request_denied ✅ Complete
Account verification_pending, verification_approved Missing support_reply, community_leader_assigned

1.6 Missing from Settings UI

The following notification types exist in code but are NOT shown in Settings:

Type Why Missing Recommended Action
post_reply Phase 3.7 Discussion notifications Add to Discussions group
comment_reply Phase 3.7 Discussion notifications Add to Discussions group
post_reaction Phase 3.7 Discussion notifications Add to Discussions group
comment_reaction Phase 3.7 Discussion notifications Add to Discussions group
support_reply CL-only notification Add to Account group (or CL section)
community_leader_assigned CL-only notification Add to Account group (or CL section)

1.7 Current Scheduler Configuration

From docs/operations/SCHEDULERS.md:

Job Schedule Status
maintenance:cleanup_sessions Daily 6:00 AM UTC ✅ Active
MessageNotificationJob.perform_later Every 10 minutes ✅ Active
Cp::WeeklyDigestJob.perform_later Daily (with Monday guard in code) ✅ Fixed — only sends Mondays
Cp::NotificationDigestJob.perform_later(digest_type: 'daily') Daily 7:00 AM Central ⏳ PENDING
Cp::NotificationDigestJob.perform_later(digest_type: 'weekly') Daily (with Monday guard in code) ⏳ PENDING

Note: Heroku Scheduler has no weekly option — only 10-min/hourly/daily. Weekly jobs must run daily with Monday guard in code.

1.8 Current Onboarding Email State

Current implementation: Single ChampionSignupMailer.welcome_email sent when:

No onboarding series exists. There is no drip campaign or multi-email sequence.

1.9 Admin Weekly Digest Bug (Lookup Portal) ✅ FIXED

Problem: Cp::WeeklyDigestJob was supposed to send a weekly admin summary to portal_admin and admin users, but it was running DAILY because Heroku Scheduler has no weekly option.

Root Cause:

Fix Applied (January 2026):

Files Modified:


2. Target State: Consolidated Digest Taxonomy

2.1 Core Principle

ONE digest email per frequency per user, not one per feature.

A Champion should receive at most:

2.2 Consolidated Daily Digest Structure

📬 Your Champion Daily Digest — January 15, 2026
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

📨 UNREAD MESSAGES (3)
┃ From Sarah Chen (2 messages) — "About the Nashville meetup..."
┃ From Mike Johnson (1 message) — "Thanks for connecting!"
┃ → View all messages

👥 NEW COMMUNITY MEMBERS
┃ Nashville District: 2 new Champions
┃   • Jane Doe '18 — Music Business
┃   • Bob Smith '22 — Nursing
┃ Music Business Community: 1 new Champion
┃ → See who joined

💬 DISCUSSION ACTIVITY
┃ National Board: 3 new replies
┃   • "Nashville Meetup Ideas" — 2 comments
┃   • "Career Advice Thread" — 1 comment
┃ Your posts received 5 reactions
┃ → View discussions

📋 JOIN REQUESTS (CL only)
┃ Nashville District: 2 pending requests
┃ → Review requests

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2.3 Consolidated Weekly Digest Structure

📬 Your Champion Weekly Digest — Week of January 13, 2026
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

📅 UPCOMING EVENTS (This Week)
┃ Nashville Coffee Meetup — Saturday, Jan 18 @ 10am
┃   Hosted by Sarah Chen • 12 attending
┃ Atlanta Happy Hour — Friday, Jan 17 @ 6pm
┃ → View all events

🎯 COMMUNITY SUGGESTIONS
┃ Based on your profile, you might like:
┃   • Music Industry Community (127 members)
┃   • Atlanta District (45 members)
┃ → Explore communities

📊 YOUR WEEK IN REVIEW
┃ Profile views: 8
┃ Messages received: 5
┃ Reactions on your posts: 12
┃ → View your profile

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2.4 Digest Section Mapping

Section Notification Types Digest Condition
Unread Messages new_message Daily If unread count > 0
New Community Members new_member Daily If any new members
Discussion Activity post_reply, comment_reply, post_reaction, comment_reaction Daily If any activity
Join Requests join_request_received Daily CL only, if pending requests
Upcoming Events new_event Weekly If events in next 7 days
Community Suggestions community_suggestion Weekly If suggestions exist
Week in Review (computed) Weekly Always show

2.5 Immediate Notification Taxonomy

These notifications are ALWAYS sent immediately and removed from Settings UI:

Type Reason Template
join_request_approved Time-sensitive confirmation Dedicated (CommunityMailer)
join_request_denied Time-sensitive notification Dedicated (CommunityMailer)
verification_pending Action required Dedicated
verification_approved Access granted Dedicated
support_reply Support thread response Dedicated
community_leader_assigned Role assignment Dedicated

2.6 Messages Section Strategy

Decision: Include UNREAD messages in daily digest with caps.

Approach Pros Cons
Include unread (capped) User sees what needs attention May duplicate if many unread

Implementation:


3. Settings UI Consolidation

3.1 Current vs Target Settings Groups

Current Group Target Group Types Notes
Messages Messages new_message Keep as-is
Contacts Contacts contact_added Keep as-is
Community Community new_member, new_event, community_suggestion Keep as-is
Join Requests Join Requests join_request_received Remove approved/denied (always immediate)
Account Account Remove all (always immediate)
(missing) Discussions post_reply, comment_reply, post_reaction, comment_reaction ADD NEW GROUP
┌─────────────────────────────────────────────────────────────────────┐
│ NOTIFICATION PREFERENCES                                            │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│ 📨 MESSAGES                                                         │
│ ├─ New Messages ..................... [✓ In-App] [Daily ▼]         │
│ │  When someone sends you a message                                 │
│                                                                     │
│ 👤 CONTACTS                                                         │
│ ├─ Contact Added .................... [✓ In-App] [Off ▼]           │
│ │  When someone adds you as a contact                               │
│                                                                     │
│ 🏘️ COMMUNITY                                                        │
│ ├─ New Members ...................... [✓ In-App] [Daily ▼]         │
│ │  When new Champions join your communities                         │
│ ├─ New Events ....................... [✓ In-App] [Weekly ▼]        │
│ │  Events posted to your communities                                │
│ ├─ Community Suggestions ............ [✓ In-App] [Weekly ▼]        │
│ │  Personalized community recommendations                           │
│                                                                     │
│ 💬 DISCUSSIONS                                           << NEW     │
│ ├─ Post Replies ..................... [✓ In-App] [Daily ▼]         │
│ │  When someone comments on your posts                              │
│ ├─ Comment Replies .................. [✓ In-App] [Daily ▼]         │
│ │  When someone replies to your comments                            │
│ ├─ Post Reactions ................... [✓ In-App] [Daily ▼]         │
│ │  When someone reacts to your posts                                │
│ ├─ Comment Reactions ................ [✓ In-App] [Daily ▼]         │
│ │  When someone reacts to your comments                             │
│                                                                     │
│ 📋 JOIN REQUESTS (Community Leaders only)                           │
│ ├─ New Requests ..................... [✓ In-App] [Daily ▼]         │
│ │  When Champions request to join your communities                  │
│                                                                     │
│ ─────────────────────────────────────────────────────────────────   │
│ ℹ️ Account notifications (verification, support replies) are        │
│    always sent immediately and cannot be adjusted.                  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

3.3 Types Removed from Settings

Type Reason Behavior
verification_pending Always time-sensitive Always immediate, always in-app
verification_approved Always time-sensitive Always immediate, always in-app
join_request_approved Always time-sensitive Always immediate, always in-app
join_request_denied Always time-sensitive Always immediate, always in-app
support_reply Support is critical Always immediate, always in-app
community_leader_assigned Role change is critical Always immediate, always in-app

3.4 CL-Only Notifications

Decision: CL-only notifications should ONLY appear in Settings UI for Champions who are Community Leaders.

Type Visibility Notes
join_request_received Show only if champion.is_community_leader? Hide entire row from non-CLs
support_reply Show only if champion.is_community_leader? CLs have support threads
community_leader_assigned Show only if champion.is_community_leader? Only CLs receive this

4. Template Standardization

4.1 Email Template Inventory

Template File Purpose Status
daily_digest.html.erb app/views/cp/notification_mailer/ Consolidated daily Exists (needs update)
weekly_digest.html.erb app/views/cp/notification_mailer/ Consolidated weekly Exists (needs update)
immediate_notification.html.erb app/views/cp/notification_mailer/ Generic immediate Exists
message_notification.html.erb app/views/cp/message_mailer/ Message alert Exists
join_request_approved.html.erb app/views/cp/community_mailer/ Join approved Exists
join_request_denied.html.erb app/views/cp/community_mailer/ Join denied Exists
welcome_email.html.erb app/views/champion_signup_mailer/ Signup welcome Exists

4.2 Template Design Requirements

All Champion Portal emails should follow consistent design:

Element Specification
Header Belmont Blue gradient with portal logo
Primary Button Belmont Blue (#001D54) with white text
Secondary Button White with blue border
Section Headers Icon + title, left-aligned
Footer Unsubscribe link, support email, portal link
Width 600px max, responsive
Font System fonts (Arial, sans-serif)

4.3 Digest Template Updates Required

Daily Digest Changes:

  1. Add Unread Messages section at top
  2. Consolidate discussion notifications by board
  3. Show CL-only sections conditionally
  4. Add time-of-day greeting (“Good morning, Sarah!”)

Weekly Digest Changes:

  1. Add Upcoming Events section with calendar layout
  2. Add Week in Review stats section
  3. Consolidate community suggestions
  4. Add “engagement streak” if applicable

4.4 Immediate Template Standardization

Create dedicated templates for immediate notifications that lack them:

Type Current Target
verification_pending Generic immediate_notification Dedicated with CTA
verification_approved Generic immediate_notification Dedicated celebration
support_reply Generic immediate_notification Dedicated with context
community_leader_assigned Generic immediate_notification Dedicated with responsibilities

5. Heroku Scheduler Plan

5.1 Current Scheduler State

✅ ACTIVE:
- maintenance:cleanup_sessions — Daily 6:00 AM UTC (midnight Central)
- rake jobs:message_notifications — Every 10 minutes

⚠️ BUG — MISCONFIGURED:
- Cp::WeeklyDigestJob — **Sending DAILY instead of weekly!** Should be Mondays only.

⏳ PENDING (not yet in Heroku):
- Cp::NotificationDigestJob daily — Target: 7:00 AM user-local time
- Cp::NotificationDigestJob weekly — Target: Mondays 7:00 AM user-local time

5.2 Time Zone Consideration

Current approach: Hardcoded to Central Time (UTC-6 / UTC-5 DST)

Target approach: Send digests at 7:00 AM in user’s district-local timezone

Implementation options:

Option Complexity Pros Cons
A. Batched by timezone Medium True 7am delivery Multiple scheduler jobs
B. Central Time for all Low Simple 5am in Hawaii, 8am in NYC
C. User-configurable time High Perfect personalization UX complexity

Recommendation: Start with Option B (Central Time), plan for Option A post-MVP.

5.3 Scheduler Configuration to Add

# Add to Heroku Scheduler:

# Daily Champion Digest — 12:00 PM UTC (7:00 AM Central winter / 6:00 AM summer)
bin/rails runner "Cp::NotificationDigestJob.perform_later(digest_type: 'daily')"

# Weekly Champion Digest — Mondays 12:00 PM UTC
bin/rails runner "Cp::NotificationDigestJob.perform_later(digest_type: 'weekly')"

5.4 Job Idempotency Requirements

Digests must be idempotent (safe to run multiple times):

Requirement Implementation
Track sent digests emailed_at timestamp on notifications
Prevent double-send Check emailed_at.nil? before including
Handle partial failures Mark as emailed only after successful send
Log all sends Rails logger + optional external logging

5.5 Monitoring & Alerting

Metric Alert Condition Action
Digest job failures > 3 consecutive failures Page on-call
Digest job duration > 5 minutes Warning
Email bounce rate > 5% Review list hygiene
Unsubscribe rate > 10% per week Review content

6. Champion Onboarding Email Series

6.1 Philosophy

Teach principles first, then point to features.

The onboarding series should:

  1. Reinforce what it means to be a Champion
  2. Introduce portal features gradually
  3. Provide clear next-step CTAs
  4. Not overwhelm with everything at once

6.2 Current State

Only one email exists: ChampionSignupMailer.welcome_email

Sent when: Champion completes signup quiz or is approved by staff

6.3 Proposed Onboarding Series

Day Email Purpose CTA
Day 0 Welcome to the Team Celebrate, explain what Champions do Complete your profile
Day 2 Your Role as [Role] Deep dive on their selected role Find Champions in your area
Day 5 The Power of Community Introduce communities, discussion boards Join a community
Day 10 Making Connections How to message, add contacts Send your first message
Day 15 Your Champion Dashboard Overview of features, notification settings Customize your settings
Day 30 Your First Month Celebrate milestones, encourage engagement Share feedback

6.4 Email Series Data Model

# New table: cp_onboarding_emails
create_table :cp_onboarding_emails do |t|
  t.references :champion, null: false, foreign_key: { to_table: :cp_champions }
  t.string :email_key, null: false  # e.g., "day_0_welcome", "day_2_role"
  t.datetime :scheduled_for, null: false
  t.datetime :sent_at
  t.datetime :opened_at
  t.datetime :clicked_at
  t.string :click_target  # Which CTA was clicked
  t.timestamps
  
  t.index [:champion_id, :email_key], unique: true
end

6.5 Email Series Job

# New job: Cp::OnboardingEmailJob
class Cp::OnboardingEmailJob < ApplicationJob
  queue_as :default
  
  def perform
    # Find all scheduled emails due now
    Cp::OnboardingEmail.where("scheduled_for <= ?", Time.current)
                       .where(sent_at: nil)
                       .find_each do |email|
      Cp::OnboardingMailer.public_send(email.email_key, email.champion).deliver_now
      email.update!(sent_at: Time.current)
    end
  end
end

6.6 Scheduler Addition

# Add to Heroku Scheduler — Every hour
bin/rails runner "Cp::OnboardingEmailJob.perform_later"

6.7 Sample Email Content

Day 0: Welcome to the Team

Subject: Welcome to Alumni Champions, Sarah! 🎉

Hi Sarah,

You did it — you're officially an Alumni Champion!

As a Community Builder, you've committed to something meaningful:
helping fellow Bruins feel at home, wherever they are.

What does that look like in practice?
• Host a coffee meetup in Nashville
• Welcome a new grad to the area
• Plan a casual gathering at your favorite spot

You don't have to do all of this today. Start small.
The portal is here to help you connect with your people.

→ Complete Your Profile

Your profile helps other Champions find you. Add a photo,
share your story, and let people know you're out there.

Once a Bruin, always a Bruin.

— The Belmont Alumni Engagement Team

7. UI Notes & Gaps

7.1 Settings Page Gaps

Gap Priority Fix
Missing Discussion notifications group High Add new “Discussions” section
Account types not removable Medium Add explanatory note instead
CL-only types visible to non-CLs Medium Conditional rendering
No notification preview Low Add “Send test” option

7.2 Notification Bell Gaps

Gap Priority Fix
Discussion notifications not distinguished Medium Add icon variation
No deep link to specific post Medium Use notification’s url field
Aggregation only for new_member Low Expand to reactions

7.3 Dashboard Widget Gaps

Gap Priority Fix
No “upcoming events” widget High Add events section
No “community activity” summary Medium Add to dashboard
No unread badge refresh Low WebSocket or polling

8. Deliverables & Checklist

8.0 Completion Summary (Phases 8.1-8.4)

Status: Phases 8.1-8.4 Complete (January 2026)

What Was Implemented:

Sub-Phase Status Key Deliverables
8.1 Settings UI ✅ Complete Discussions group (4 types), CL-only conditionals, explanatory note
8.2 Discussion Notifications ✅ Complete Discussion types in digests, aggregated by board
8.3 Scheduler Activation ✅ Complete Monday guard for WeeklyDigestJob, SCHEDULERS.md updated
8.4 Immediate Templates ✅ Complete 8 dedicated templates (HTML+text) for account notifications
8.5 Onboarding Series Not Started Deferred to future work
8.6 Testing & Documentation ✅ Complete All tests passing, CHANGELOG updated

Files Created/Modified:

Tests: 2551 runs, 0 failures, 0 errors

Deferred to Phase 8.5+:


8.1 Sub-Phase Breakdown

Sub-Phase Name Deliverables
8.1 Settings UI Updates Add Discussions group, CL-only conditionals, explanatory text
8.2 Digest Consolidation Update daily/weekly templates, add messages section
8.3 Scheduler Fixes & Activation Fix Admin weekly→daily bug, activate champion digests
8.4 Immediate Templates Create dedicated templates for account notifications
8.5 Onboarding Series Data model, job, 6 email templates
8.6 Testing & Documentation Tests, docs, CHANGELOG

8.2 Settings UI Updates (8.1)

8.3 Digest Consolidation (8.2)

8.4 Scheduler Fixes & Activation (8.3)

Heroku Scheduler Limitation: Only supports 10-min / hourly / daily — no weekly option. Pattern: Schedule daily, add return unless Time.zone.today.monday? guard in job.

Bug Fix:

Champion Digests:

8.5 Immediate Templates (8.4)

8.6 Onboarding Series (8.5)

8.7 Testing & Documentation (8.6)

8.8 Web Push Notifications (8.7)


9. Web Push Notifications (Sub-Phase 8.7)

Scope: Lookup Portal staff only (single implementation)
Focus: Immediate notifications for time-sensitive actions
Approach: Self-hosted using Web Push API with VAPID keys

9.1 Overview

Web push notifications allow staff to receive browser notifications even when the Alumni Lookup portal is not open. This is particularly valuable for time-sensitive actions like Champion verification requests.

Key Insight: Only one implementation needed for staff. Push subscriptions are tied to:

Since staff authenticate only via Lookup Portal (alumnilookup.com), enabling push there covers all staff notifications regardless of which page triggered them.

Primary Use Cases:

  1. New Champion needs verification — Someone signed up and needs BUID linking
  2. New support thread — Champion submitted a support request
  3. New beta feedback — User submitted feedback

9.1.1 Notification Types for Push

Type Push Example
pending_verification “New Champion needs verification: Jane Smith”
support_thread “New support request from Sarah Johnson”
beta_feedback “New feedback from Mike Champion”

9.2 Technical Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ 1. USER OPTS IN                                                     │
├─────────────────────────────────────────────────────────────────────┤
│ Staff clicks "Enable notifications" → Browser prompts permission    │
│ If granted → Browser returns PushSubscription (endpoint + keys)     │
│ Rails saves subscription to `push_subscriptions` table              │
└─────────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────────┐
│ 2. NOTIFICATION CREATED                                             │
├─────────────────────────────────────────────────────────────────────┤
│ StaffNotification.create! → after_create callback                   │
│ Callback enqueues WebPushJob for each subscription                  │
└─────────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────────┐
│ 3. PUSH SENT                                                        │
├─────────────────────────────────────────────────────────────────────┤
│ WebPushJob calls WebPushService.send_notification                   │
│ WebPushService uses `web-push` gem with VAPID keys                  │
│ Push server (Google/Mozilla) delivers to browser                    │
└─────────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────────┐
│ 4. BROWSER RECEIVES                                                 │
├─────────────────────────────────────────────────────────────────────┤
│ Service Worker's `push` event fires                                 │
│ Shows native notification with title, body, icon                    │
│ User clicks → opens app at notification URL                         │
└─────────────────────────────────────────────────────────────────────┘

9.3 Data Model

push_subscriptions Table

create_table :push_subscriptions do |t|
  # Polymorphic: can belong to User (staff) or Cp::Champion (future)
  t.references :subscribable, polymorphic: true, null: false
  
  # Web Push subscription data
  t.string :endpoint, null: false
  t.string :p256dh_key, null: false
  t.string :auth_key, null: false
  
  # Metadata
  t.string :user_agent        # Browser/device info
  t.datetime :last_used_at    # Track engagement
  t.datetime :failed_at       # Mark failed subscriptions
  t.integer :failure_count, default: 0
  
  t.timestamps
  
  t.index [:subscribable_type, :subscribable_id]
  t.index :endpoint, unique: true
end

Model

class PushSubscription < ApplicationRecord
  belongs_to :subscribable, polymorphic: true
  
  validates :endpoint, presence: true, uniqueness: true
  validates :p256dh_key, presence: true
  validates :auth_key, presence: true
  
  scope :active, -> { where(failed_at: nil) }
  scope :for_user, ->(user) { where(subscribable: user) }
  
  # Mark as failed after repeated delivery failures
  def mark_failed!
    increment!(:failure_count)
    update!(failed_at: Time.current) if failure_count >= 3
  end
  
  def reactivate!
    update!(failed_at: nil, failure_count: 0)
  end
end

9.4 VAPID Keys

VAPID (Voluntary Application Server Identification) keys authenticate push requests.

Generate keys:

# In Rails console
require 'web-push'
vapid_key = WebPush.generate_key
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"

Store in credentials:

# config/credentials.yml.enc
web_push:
  vapid_public_key: "BLQELIDm-6b2..."
  vapid_private_key: "Dt1CLg..."
  vapid_subject: "mailto:support@alumnilookup.com"

9.5 Service Worker

File: public/service-worker.js

// Service Worker for Web Push Notifications
// Handles push events and notification clicks

self.addEventListener('push', function(event) {
  if (!event.data) return;
  
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: '/android-chrome-192x192.png',
    badge: '/favicon-96x96.png',
    tag: data.tag || 'default',
    data: {
      url: data.url || '/'
    },
    requireInteraction: true,  // Keep visible until dismissed
    vibrate: [200, 100, 200]
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  
  const url = event.notification.data.url;
  
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(function(clientList) {
      // If app is already open, focus it
      for (const client of clientList) {
        if (client.url.includes(self.location.origin) && 'focus' in client) {
          client.focus();
          client.navigate(url);
          return;
        }
      }
      // Otherwise open new window
      if (clients.openWindow) {
        return clients.openWindow(url);
      }
    })
  );
});

9.6 Web App Manifest

File: public/manifest.json

{
  "name": "Alumni Lookup",
  "short_name": "Alumni Lookup",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#001D54",
  "theme_color": "#001D54",
  "icons": [
    {
      "src": "/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

9.7 WebPushService

# app/services/web_push_service.rb
class WebPushService
  class << self
    def send_to_user(user, title:, body:, url: nil, tag: nil)
      user.push_subscriptions.active.find_each do |subscription|
        WebPushJob.perform_later(
          subscription_id: subscription.id,
          title: title,
          body: body,
          url: url,
          tag: tag
        )
      end
    end
    
    def send_notification(subscription, title:, body:, url: nil, tag: nil)
      message = {
        title: title,
        body: body,
        url: url,
        tag: tag
      }.compact.to_json
      
      WebPush.payload_send(
        message: message,
        endpoint: subscription.endpoint,
        p256dh: subscription.p256dh_key,
        auth: subscription.auth_key,
        vapid: vapid_options
      )
      
      subscription.touch(:last_used_at)
      true
    rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription
      subscription.destroy
      false
    rescue WebPush::ResponseError => e
      subscription.mark_failed!
      Rails.logger.error "WebPush failed: #{e.message}"
      false
    end
    
    private
    
    def vapid_options
      {
        subject: Rails.application.credentials.dig(:web_push, :vapid_subject),
        public_key: Rails.application.credentials.dig(:web_push, :vapid_public_key),
        private_key: Rails.application.credentials.dig(:web_push, :vapid_private_key)
      }
    end
  end
end

9.8 WebPushJob

# app/jobs/web_push_job.rb
class WebPushJob < ApplicationJob
  queue_as :default
  
  def perform(subscription_id:, title:, body:, url: nil, tag: nil)
    subscription = PushSubscription.find_by(id: subscription_id)
    return unless subscription&.active?
    
    WebPushService.send_notification(
      subscription,
      title: title,
      body: body,
      url: url,
      tag: tag
    )
  end
end

9.9 Integration with StaffNotification

Step 1: Add pending_verification type to StaffNotification

# app/models/staff_notification.rb
NOTIFICATION_TYPES = %w[
  support_thread
  beta_feedback
  pending_verification  # NEW
].freeze

# Add class method for verification notifications
def self.notify_portal_admins_of_new_signup(champion)
  User.where(role: ['admin', 'portal_admin']).find_each do |user|
    create!(
      user: user,
      notification_type: "pending_verification",
      title: "New Champion needs verification",
      body: "#{champion.display_name} signed up and needs BUID linking",
      url: "/champions/verifications",
      notifiable: champion
    )
  end
end

Step 2: Add callback for web push

# app/models/staff_notification.rb
after_create :send_web_push

private

def send_web_push
  return unless user.push_subscriptions.active.exists?
  
  WebPushService.send_to_user(
    user,
    title: title,
    body: body,
    url: url,
    tag: "staff-notification-#{id}"
  )
end

Step 3: Hook into existing NotifyAdminsJob

# app/jobs/cp/notify_admins_job.rb
def perform(champion_id)
  champion = Cp::Champion.find_by(id: champion_id)
  return unless champion

  # Existing: Send email
  mail = Cp::AdminNotificationMailer.new_champion_signup(champion)
  mail&.deliver_now
  
  # NEW: Create in-app notification (only if needs verification)
  if champion.status_email_verified? && !champion.status_champion_verified?
    StaffNotification.notify_portal_admins_of_new_signup(champion)
  end
end

9.10 JavaScript: Permission & Subscription

File: app/javascript/controllers/push_subscription_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["enableButton", "status"]
  static values = {
    vapidPublicKey: String,
    subscribeUrl: String,
    unsubscribeUrl: String
  }
  
  connect() {
    this.checkSupport()
    this.updateUI()
  }
  
  checkSupport() {
    this.supported = 'serviceWorker' in navigator && 'PushManager' in window
    if (!this.supported) {
      this.showStatus("Push notifications not supported in this browser")
      this.enableButtonTarget.disabled = true
    }
  }
  
  async updateUI() {
    if (!this.supported) return
    
    const permission = Notification.permission
    const subscription = await this.getSubscription()
    
    if (permission === 'denied') {
      this.showStatus("Notifications blocked. Please enable in browser settings.")
      this.enableButtonTarget.disabled = true
    } else if (subscription) {
      this.showStatus("Notifications enabled ✓")
      this.enableButtonTarget.textContent = "Disable Notifications"
      this.enableButtonTarget.dataset.action = "push-subscription#unsubscribe"
    } else {
      this.showStatus("Notifications not enabled")
      this.enableButtonTarget.textContent = "Enable Notifications"
      this.enableButtonTarget.dataset.action = "push-subscription#subscribe"
    }
  }
  
  async subscribe(event) {
    event.preventDefault()
    
    try {
      // Register service worker
      const registration = await navigator.serviceWorker.register('/service-worker.js')
      
      // Request permission
      const permission = await Notification.requestPermission()
      if (permission !== 'granted') {
        this.showStatus("Permission denied")
        return
      }
      
      // Subscribe to push
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKeyValue)
      })
      
      // Send to server
      await this.saveSubscription(subscription)
      this.updateUI()
      
    } catch (error) {
      console.error('Subscription failed:', error)
      this.showStatus("Failed to enable notifications")
    }
  }
  
  async unsubscribe(event) {
    event.preventDefault()
    
    try {
      const subscription = await this.getSubscription()
      if (subscription) {
        await subscription.unsubscribe()
        await this.removeSubscription(subscription)
      }
      this.updateUI()
    } catch (error) {
      console.error('Unsubscribe failed:', error)
    }
  }
  
  async getSubscription() {
    const registration = await navigator.serviceWorker.getRegistration()
    if (!registration) return null
    return registration.pushManager.getSubscription()
  }
  
  async saveSubscription(subscription) {
    const response = await fetch(this.subscribeUrlValue, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
      },
      body: JSON.stringify({ subscription: subscription.toJSON() })
    })
    
    if (!response.ok) throw new Error('Failed to save subscription')
  }
  
  async removeSubscription(subscription) {
    await fetch(this.unsubscribeUrlValue, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
      },
      body: JSON.stringify({ endpoint: subscription.endpoint })
    })
  }
  
  showStatus(message) {
    if (this.hasStatusTarget) {
      this.statusTarget.textContent = message
    }
  }
  
  urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4)
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
    const rawData = window.atob(base64)
    return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
  }
}

9.11 Controller: PushSubscriptionsController

# app/controllers/push_subscriptions_controller.rb
class PushSubscriptionsController < ApplicationController
  before_action :authenticate_user!
  
  def create
    subscription_params = params.require(:subscription).permit(:endpoint, keys: [:p256dh, :auth])
    
    subscription = current_user.push_subscriptions.find_or_initialize_by(
      endpoint: subscription_params[:endpoint]
    )
    
    subscription.assign_attributes(
      p256dh_key: subscription_params.dig(:keys, :p256dh),
      auth_key: subscription_params.dig(:keys, :auth),
      user_agent: request.user_agent,
      failed_at: nil,
      failure_count: 0
    )
    
    if subscription.save
      render json: { status: 'subscribed' }
    else
      render json: { error: subscription.errors.full_messages }, status: :unprocessable_entity
    end
  end
  
  def destroy
    endpoint = params[:endpoint]
    current_user.push_subscriptions.find_by(endpoint: endpoint)&.destroy
    render json: { status: 'unsubscribed' }
  end
end

9.12 UI Integration

Settings Page Addition:

<%# In app/views/settings/_notifications_push.html.erb %>
<div class="bg-white border border-gray-200 rounded-lg p-6 mb-6"
     data-controller="push-subscription"
     data-push-subscription-vapid-public-key-value="<%= Rails.application.credentials.dig(:web_push, :vapid_public_key) %>"
     data-push-subscription-subscribe-url-value="<%= push_subscriptions_path %>"
     data-push-subscription-unsubscribe-url-value="<%= push_subscriptions_path %>">
  
  <div class="flex items-center justify-between">
    <div>
      <h3 class="text-lg font-medium text-gray-900">Browser Notifications</h3>
      <p class="text-sm text-gray-500 mt-1" data-push-subscription-target="status">
        Checking notification status...
      </p>
    </div>
    
    <button type="button"
            data-push-subscription-target="enableButton"
            data-action="push-subscription#subscribe"
            class="px-4 py-2 bg-belmontblue text-white rounded-lg hover:bg-admissionsblue">
      Enable Notifications
    </button>
  </div>
  
  <p class="text-xs text-gray-400 mt-3">
    Get instant alerts when Champions need verification or support requests come in.
  </p>
</div>

9.13 Lookup Portal Notification Types for Push

All staff notification types will support push:

Type Push Enabled Use Case
pending_verification ✅ Yes Champion signed up, needs BUID linking
support_thread ✅ Yes Champion submitted support request
beta_feedback ✅ Yes User submitted feedback

9.14 Files to Create

# Gem
Gemfile                                    # Add web-push gem

# Database
db/migrate/YYYYMMDD_create_push_subscriptions.rb

# Models
app/models/push_subscription.rb

# Services
app/services/web_push_service.rb

# Jobs
app/jobs/web_push_job.rb

# Controllers
app/controllers/push_subscriptions_controller.rb

# JavaScript
app/javascript/controllers/push_subscription_controller.js

# Public files
public/service-worker.js
public/manifest.json

# Views
app/views/settings/_notifications_push.html.erb

# Tests
test/models/push_subscription_test.rb
test/services/web_push_service_test.rb
test/jobs/web_push_job_test.rb
test/controllers/push_subscriptions_controller_test.rb

9.15 Files to Modify

Gemfile                                    # Add web-push gem
config/routes.rb                           # Add push subscription routes
app/models/user.rb                         # Add has_many :push_subscriptions
app/models/staff_notification.rb           # Add after_create :send_web_push
app/views/layouts/application.html.erb     # Add manifest link
app/views/settings/index.html.erb          # Add push notification section
config/credentials.yml.enc                 # Add VAPID keys

9.16 Browser Support

Browser Support Notes
Chrome (desktop) ✅ Full Best support
Chrome (Android) ✅ Full Works great
Firefox (desktop) ✅ Full Good support
Edge ✅ Full Chromium-based
Safari (macOS 13+) ✅ Full Added in 2023
Safari (iOS 16.4+) ⚠️ PWA only Must add to home screen

9.17 Future: Champion Portal

Once Lookup Portal push notifications are working, the same infrastructure can be extended to Champion Portal:

  1. Add has_many :push_subscriptions, as: :subscribable to Cp::Champion
  2. Create Cp::PushSubscriptionsController with champion auth
  3. Integrate with Cp::NotificationService.create() for immediate types
  4. Add UI to Champion Portal settings

Immediate notification types for Champion Portal:

8.3 Files to Create

app/models/cp/onboarding_email.rb
app/mailers/cp/onboarding_mailer.rb
app/jobs/cp/onboarding_email_job.rb
app/views/cp/onboarding_mailer/day_0_welcome.html.erb
app/views/cp/onboarding_mailer/day_0_welcome.text.erb
app/views/cp/onboarding_mailer/day_2_role.html.erb
app/views/cp/onboarding_mailer/day_2_role.text.erb
app/views/cp/onboarding_mailer/day_5_community.html.erb
app/views/cp/onboarding_mailer/day_5_community.text.erb
app/views/cp/onboarding_mailer/day_10_connections.html.erb
app/views/cp/onboarding_mailer/day_10_connections.text.erb
app/views/cp/onboarding_mailer/day_15_dashboard.html.erb
app/views/cp/onboarding_mailer/day_15_dashboard.text.erb
app/views/cp/onboarding_mailer/day_30_first_month.html.erb
app/views/cp/onboarding_mailer/day_30_first_month.text.erb
db/migrate/YYYYMMDD_create_cp_onboarding_emails.rb
test/models/cp/onboarding_email_test.rb
test/mailers/cp/onboarding_mailer_test.rb
test/jobs/cp/onboarding_email_job_test.rb
docs/features/champion_portal/ONBOARDING_EMAIL_SERIES.md

8.4 Files to Modify

app/views/cp/settings/_section_notifications.html.erb
app/views/cp/notification_mailer/daily_digest.html.erb
app/views/cp/notification_mailer/weekly_digest.html.erb
app/jobs/cp/notification_digest_job.rb
app/helpers/cp/settings_helper.rb (if exists)
docs/operations/SCHEDULERS.md
docs/CHANGELOG.md

Questions to Resolve

Question Status Decision
Should messages section show unread only or all recent? ✅ Resolved Unread only (capped at 5)
Per-user timezone or Central Time for all? ✅ Resolved Central Time for MVP, batch later
Show CL notifications to all users or CL-only? ✅ Resolved CL-only — hide from non-CLs
Onboarding series timing (days)? ✅ Resolved Days 0, 2, 5, 10, 15, 30 confirmed
Track email opens/clicks? ✅ Resolved No — not needed currently

Success Criteria

Metric Target
Champions receiving > 1 daily digest 0%
Champions receiving > 1 weekly digest 0%
Onboarding series completion rate (Day 30) > 60%
Digest open rate > 40%
Settings page visits (new Discussions group) Trackable

Phase 8 consolidates notifications into a sustainable, user-friendly system that Champions can trust and staff can maintain.