alumni_lookup

Sub-Phase 1.16: Notifications System

Champion Portal Development Sub-Phase 1.16

Estimated Effort: 3-4 weeks Focus: In-app notifications, email digests, navbar integration

Prerequisites: Phase 1.15 complete (Communities system)

Status: ✅ COMPLETE (January 2026)

Related Documents:


Completion Summary

Implemented: January 2026

What Was Built

Database & Models:

Services & Jobs:

Email System:

Navbar UI:

Settings Integration:

Files Created:

Tests: 1931 tests, 0 failures

Integration Points

Deferred


Table of Contents

  1. Overview
  2. Design Decisions
  3. Database Schema
  4. Notification Types & Triggers
  5. Aggregation Logic
  6. User Preferences
  7. Email Digest System
  8. Navbar UI
  9. Implementation Plan
  10. Tests to Create
  11. Documentation Updates

1. Overview

Phase 1.16 implements a comprehensive notifications system for the Champion Portal:

Goals

  1. Keep Champions informed without overwhelming them
  2. Provide unified notification center (not scattered across pages)
  3. Enable configurable preferences per notification type
  4. Support email digests for champions who don’t visit frequently

User Experience After Phase 1.16


2. Design Decisions

Decisions made during Phase 1.16 planning interview (January 2026):

Topic Decision Rationale
Notification lifecycle Read-only with background cleanup Users can’t delete; system cleans up old notifications
Click tracking Track clicked_at separately from read_at Enables analytics on engagement
New member aggregation Max ONE unread notification per community Prevents notification flood in active communities
Event notifications One per event (even if multi-community) Avoids duplicate notifications for same event
Additional triggers Any email → also create notification Join requests, verification pending, etc.
In-app preferences Configurable per type Users can turn off specific notification types
Email frequency max Daily for high-volume (new_member) Weekly/daily depending on type
Default preferences In-app ON for all, email at max interval Weekly for low-volume, daily for high-volume
Daily digest time 7am local time Morning delivery for engagement
Weekly digest Monday 7am local Start of week summary
Digest format Combined single email All types in one email, not separate
Unread indicator Dot only (not count) Cleaner UI, less anxiety-inducing
Messages indicator Dot only in bottom menu Consistent with notification style
Dropdown style Dropdown or slideout, mobile-first Design for thumb zones
Mark all as read Yes Quick way to clear notifications
Real-time updates Page refresh only Simpler implementation for MVP
Creation method Async via background jobs Non-blocking, scalable

3. Database Schema

3.1 cp_notifications Table

create_table :cp_notifications do |t|
  # Owner
  t.references :cp_champion, null: false, foreign_key: true, index: true

  # Notification type (enum)
  t.string :notification_type, null: false, index: true

  # Polymorphic reference to source object
  t.string :notifiable_type
  t.bigint :notifiable_id
  t.index [:notifiable_type, :notifiable_id]

  # State tracking
  t.datetime :read_at
  t.datetime :clicked_at
  t.datetime :emailed_at

  # Aggregation support
  t.string :aggregation_key  # e.g., "new_member:community:123"
  t.integer :aggregation_count, default: 1  # For "X new members" display

  # Content (denormalized for performance/cleanup)
  t.string :title
  t.text :body
  t.string :url  # Where clicking takes you

  t.timestamps
end

add_index :cp_notifications, [:cp_champion_id, :read_at]
add_index :cp_notifications, [:cp_champion_id, :notification_type, :aggregation_key], 
          name: 'idx_notifications_aggregation'
add_index :cp_notifications, :created_at  # For cleanup job

3.2 cp_notification_preferences Table

create_table :cp_notification_preferences do |t|
  t.references :cp_champion, null: false, foreign_key: true

  # Notification type this preference applies to
  t.string :notification_type, null: false

  # Preferences
  t.boolean :in_app_enabled, default: true
  t.string :email_frequency, default: 'weekly'  # 'off', 'immediate', 'daily', 'weekly'

  t.timestamps
end

add_index :cp_notification_preferences, 
          [:cp_champion_id, :notification_type], 
          unique: true,
          name: 'idx_notification_prefs_unique'

4. Notification Types & Triggers

4.1 Notification Type Enum

# app/models/cp/notification.rb
class Cp::Notification < ApplicationRecord
  NOTIFICATION_TYPES = %w[
    new_message
    new_member
    new_event
    community_suggestion
    join_request_received
    join_request_approved
    join_request_denied
    verification_pending
    verification_approved
  ].freeze

  enum :notification_type, NOTIFICATION_TYPES.index_by(&:itself)
end

4.2 Trigger Points

Type Trigger Notifiable Recipients
new_message Message sent Cp::Message Thread participants (except sender)
new_member Champion joins community Cp::CommunityMembership All community members (aggregated)
new_event Event published Cp::Event Event’s community members
community_suggestion New community created Cp::Community Auto-assigned members
join_request_received Champion requests to join Cp::CommunityMembership Community leaders
join_request_approved Request approved Cp::CommunityMembership Requesting champion
join_request_denied Request denied Cp::CommunityMembership Requesting champion
verification_pending Champion submits verification Cp::Champion Champion (confirmation)
verification_approved Staff verifies champion Cp::Champion Champion

4.3 Trigger Implementation Pattern

# In service or callback
Cp::NotificationJob.perform_later(
  notification_type: :new_member,
  notifiable: membership,
  triggered_by: current_champion
)

5. Aggregation Logic

5.1 New Member Aggregation

Rule: Max ONE unread new_member notification per community per champion.

When a new member joins:

  1. Find existing unread new_member notification for this community
  2. If exists: increment aggregation_count, update body and updated_at
  3. If not: create new notification
# Cp::NotificationService
def create_new_member_notification(membership)
  community = membership.community
  
  community.members.where.not(id: membership.cp_champion_id).find_each do |member|
    existing = member.notifications
                     .new_member
                     .where(read_at: nil, aggregation_key: "new_member:community:#{community.id}")
                     .first
    
    if existing
      existing.increment!(:aggregation_count)
      existing.update!(
        body: pluralized_body(existing.aggregation_count, community),
        updated_at: Time.current
      )
    else
      member.notifications.create!(
        notification_type: :new_member,
        notifiable: membership,
        aggregation_key: "new_member:community:#{community.id}",
        aggregation_count: 1,
        title: community.name,
        body: "#{membership.champion.display_name} joined your community",
        url: community_path(community)
      )
    end
  end
end

def pluralized_body(count, community)
  if count == 1
    # This shouldn't happen, but handle gracefully
    "1 new member joined #{community.name}"
  else
    "#{count} new members joined #{community.name}"
  end
end

5.2 Display Logic

# In notification partial
<% if notification.aggregation_count > 1 %>
  <%= notification.aggregation_count %> new members joined <%= notification.title %>
<% else %>
  <%= notification.body %>
<% end %>

6. User Preferences

6.1 Default Preferences

Notification Type In-App Default Email Default
new_message ✅ ON Daily
new_member ✅ ON Daily
new_event ✅ ON Weekly
community_suggestion ✅ ON Weekly
join_request_received ✅ ON Daily
join_request_approved ✅ ON Immediate
join_request_denied ✅ ON Immediate
verification_pending ✅ ON Immediate
verification_approved ✅ ON Immediate

6.2 Email Frequency Options

Frequency Description
off No emails for this type
immediate Send email as soon as notification created
daily Include in daily digest (7am local)
weekly Include in weekly digest (Monday 7am local)

6.3 Settings Page UI

Add to existing Settings page under Notifications section:

┌─────────────────────────────────────────────────────────────────────────┐
│ Notification Preferences                                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│ Messages                                              In-App    Email   │
│ When someone sends you a message                      [✓]      [Daily ▼]│
│                                                                         │
│ New Members                                                             │
│ When someone joins your communities                   [✓]      [Daily ▼]│
│                                                                         │
│ Events                                                                  │
│ When new events are posted                            [✓]      [Weekly▼]│
│                                                                         │
│ Community Suggestions                                                   │
│ When you're added to a new community                  [✓]      [Weekly▼]│
│                                                                         │
│ Join Requests                                                           │
│ When someone requests to join your community          [✓]      [Daily ▼]│
│                                                                         │
│ [Save Preferences]                                                      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.4 Preference Model

# app/models/cp/notification_preference.rb
class Cp::NotificationPreference < ApplicationRecord
  belongs_to :champion, class_name: 'Cp::Champion', foreign_key: 'cp_champion_id'
  
  EMAIL_FREQUENCIES = %w[off immediate daily weekly].freeze
  
  validates :notification_type, inclusion: { in: Cp::Notification::NOTIFICATION_TYPES }
  validates :email_frequency, inclusion: { in: EMAIL_FREQUENCIES }
  
  # Get preference for a champion/type, creating with defaults if needed
  def self.for(champion, notification_type)
    find_or_create_by(cp_champion_id: champion.id, notification_type: notification_type) do |pref|
      pref.in_app_enabled = true
      pref.email_frequency = default_email_frequency(notification_type)
    end
  end
  
  def self.default_email_frequency(type)
    case type.to_s
    when 'new_message', 'new_member', 'join_request_received'
      'daily'
    when 'join_request_approved', 'join_request_denied', 'verification_pending', 'verification_approved'
      'immediate'
    else
      'weekly'
    end
  end
end

7. Email Digest System

7.1 Digest Schedule

Digest Type Schedule Content
Daily 7am local time All daily frequency notifications from past 24 hours
Weekly Monday 7am local All weekly frequency notifications from past 7 days

7.2 Combined Email Format

Single email containing all notification types:

Subject: Your Belmont Champions Update

Hey Sarah! 👋

Here's what's been happening in your Belmont communities:

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

💬 MESSAGES (3 new)
───────────────────────────────────────────────
• John Smith sent you a message
• Nashville Community: New group message
• College of Business: Jane Doe replied

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

👥 NEW MEMBERS (7 across 3 communities)
───────────────────────────────────────────────
• Nashville Community: 4 new members joined
• College of Business: 2 new members joined
• Music Industry: 1 new member joined

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

📅 EVENTS (2 new)
───────────────────────────────────────────────
• Nashville Coffee Meetup — Jan 20, 2026
• Business Networking Night — Jan 25, 2026

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

[View All Notifications →]

You're receiving this because you've enabled digest emails.
Update preferences: [Settings]

7.3 Digest Job

# app/jobs/cp/notification_digest_job.rb
class Cp::NotificationDigestJob < ApplicationJob
  queue_as :default
  
  def perform(frequency:)
    # frequency: 'daily' or 'weekly'
    time_window = frequency == 'daily' ? 24.hours.ago : 7.days.ago
    
    Cp::Champion.verified.find_each do |champion|
      notifications = champion.notifications
                              .where(emailed_at: nil)
                              .where('created_at >= ?', time_window)
                              .joins_preferences_with_frequency(frequency)
                              .order(:notification_type, :created_at)
      
      next if notifications.empty?
      
      Cp::NotificationMailer.digest(champion, notifications, frequency).deliver_later
      
      notifications.update_all(emailed_at: Time.current)
    end
  end
end

7.4 Scheduler (Heroku Scheduler or whenever gem)

# Daily digest at 7am (run hourly, check local time)
Cp::NotificationDigestJob.perform_later(frequency: 'daily')

# Weekly digest on Mondays (run daily, check if Monday)
Cp::NotificationDigestJob.perform_later(frequency: 'weekly')

Implementation Note: For local time handling, either:


8. Navbar UI

8.1 Header Layout (Desktop)

┌─────────────────────────────────────────────────────────────────────────┐
│ [Logo]   Communities   Directory              �•   🔔•   [Profile ▼]  │
└─────────────────────────────────────────────────────────────────────────┘

8.2 Bottom Menu (Mobile)

┌─────────────────────────────────────────────────────────────────────────┐
│  🏠        🏘️        👥        💬•        🔔•        👤                │
│  Home   Communities Directory Messages  Alerts    Profile              │
└─────────────────────────────────────────────────────────────────────────┘

8.3 Notification Dropdown/Slideout (Mobile-First)

Mobile: Bottom slideout panel (thumb-zone friendly)

┌─────────────────────────────────────────────────────────────────────────┐
│ ──────                                                    [Mark all read]│
│                                                                         │
│ Notifications                                                           │
│                                                                         │
│ ┌───────────────────────────────────────────────────────────────────────┤
│ │ • Nashville Community                                        2h ago   │
│ │   3 new members joined your community                                 │
│ ├───────────────────────────────────────────────────────────────────────┤
│ │ • Business Networking Event                                  1d ago   │
│ │   New event in College of Business                                    │
│ ├───────────────────────────────────────────────────────────────────────┤
│ │   John Smith                                                 2d ago   │
│ │   Sent you a message                                                  │
│ └───────────────────────────────────────────────────────────────────────┘
│                                                                         │
│ [View All Notifications]                                                │
└─────────────────────────────────────────────────────────────────────────┘

Desktop: Dropdown panel (similar content)

8.4 Unread Indicator

Simple red dot (not count):

<div class="relative">
  <%= heroicon "bell", variant: :outline, options: { class: "w-6 h-6" } %>
  <% if current_cp_champion.unread_notifications? %>
    <span class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full"></span>
  <% end %>
</div>

8.5 Mark All as Read

# POST /cp/notifications/mark_all_read
def mark_all_read
  current_cp_champion.notifications.unread.update_all(read_at: Time.current)
  
  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.replace("notifications-dropdown", partial: "cp/notifications/dropdown") }
    format.html { redirect_back fallback_location: cp_dashboard_path }
  end
end

9. Implementation Plan

Phase 1.16.1: Database & Models (2-3 days)

Phase 1.16.2: Notification Service & Jobs (2-3 days)

Phase 1.16.3: Navbar Integration (2-3 days)

Phase 1.16.4: Settings Integration (1-2 days)

Phase 1.16.5: Email Digests (2-3 days)

Phase 1.16.6: Cleanup & Polish (1-2 days)


10. Tests to Create

Model Tests

# test/models/cp/notification_test.rb
- validates notification_type inclusion
- belongs_to champion
- belongs_to notifiable (polymorphic, optional)
- scope :unread returns notifications where read_at is nil
- scope :for_type filters by notification_type
- mark_read! sets read_at
- mark_clicked! sets clicked_at

# test/models/cp/notification_preference_test.rb
- validates notification_type inclusion
- validates email_frequency inclusion
- .for creates with defaults if not exists
- .for returns existing if exists
- default_email_frequency returns correct defaults

Service Tests

# test/services/cp/notification_service_test.rb
- creates notification for recipient
- respects in_app_enabled preference
- aggregates new_member notifications
- increments aggregation_count for existing unread
- creates separate notification if existing is read
- sends immediate email if preference is immediate
- skips email if preference is off

Controller Tests

# test/controllers/cp/notifications_controller_test.rb
- index requires authentication
- index returns recent notifications
- mark_read updates read_at
- mark_all_read updates all unread notifications
- responds to turbo_stream format

Job Tests

# test/jobs/cp/notification_job_test.rb
- creates notifications via service
- handles missing notifiable gracefully

# test/jobs/cp/notification_digest_job_test.rb
- sends daily digest to eligible champions
- sends weekly digest to eligible champions
- skips champions with no pending notifications
- marks notifications as emailed
- respects email frequency preferences

Mailer Tests

# test/mailers/cp/notification_mailer_test.rb
- digest includes all notification types
- digest groups by type
- digest respects frequency filter
- includes unsubscribe link

11. Documentation Updates

After completing Phase 1.16:


Files to Create

New Files

Files to Modify


Questions Resolved

Question Answer Notes
Delete or read-only? Read-only with cleanup Background job cleans up old notifications
Track clicks? Yes, separate from read clicked_at column
New member audience? All members, aggregated Max 1 unread per community
Event duplicates? One per event Even if multi-community
In-app configurable? Yes, per type Settings page toggle
Email max frequency? Daily for high-volume new_member, join_request_received
Defaults? In-app ON, email at max Weekly or daily per type
Digest time? 7am local Central time for MVP
Weekly day? Monday 7am
Combined email? Yes All types in one email
Unread indicator? Dot only Not count
Messages dot? Yes, in bottom menu Consistent style
Dropdown style? Mobile-first slideout Desktop dropdown
Mark all read? Yes Button in dropdown
Real-time? Page refresh No WebSockets for MVP
Async creation? Yes, background jobs Non-blocking
Email digests MVP? Yes Basic implementation

Created: January 12, 2026 Status: Planning Complete — Ready for Implementation