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:
- ../../BACKLOG.md — §6.3 In-App Notification Dropdown, §6.3 Community-Centric Email Notifications
- ../../JOBS-TO-BE-DONE.md — Job C5: Stay in the Loop
Implemented: January 2026
Database & Models:
cp_notifications table with aggregation support (count, last_aggregated_at)cp_notification_preferences table for per-type settings (in_app_enabled, email_frequency)Cp::Notification model with scopes (unread, for_champion, recent_first)Cp::NotificationPreference model with DEFAULT_PREFERENCESnotification_preference_for(type) methodServices & Jobs:
Cp::NotificationService — Creates notifications with smart aggregation for high-volume typesCp::NotificationJob — Background job wrapper for async notification creationCp::NotificationDigestJob — Sends daily (7am) and weekly (Monday 7am) email digestsCp::NotificationCleanupJob — Removes read notifications older than 30 daysEmail System:
Cp::NotificationMailer with immediate_notification, daily_digest, weekly_digest actionsNavbar UI:
Settings Integration:
Files Created:
app/models/cp/notification.rbapp/models/cp/notification_preference.rbapp/services/cp/notification_service.rbapp/jobs/cp/notification_job.rbapp/jobs/cp/notification_digest_job.rbapp/jobs/cp/notification_cleanup_job.rbapp/controllers/cp/notifications_controller.rbapp/helpers/cp/notifications_helper.rbapp/javascript/controllers/notifications_controller.jsapp/mailers/cp/notification_mailer.rbapp/views/cp/notifications/ (index, _notification, _dropdown)app/views/cp/notification_mailer/ (6 template files)db/migrate/*_create_cp_notifications.rbdb/migrate/*_create_cp_notification_preferences.rbTests: 1931 tests, 0 failures
new_message notificationsnew_member and join request notificationsnew_event notificationsPhase 1.16 implements a comprehensive notifications system for the Champion Portal:
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 |
cp_notifications Tablecreate_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
cp_notification_preferences Tablecreate_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'
# 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
| 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 |
# In service or callback
Cp::NotificationJob.perform_later(
notification_type: :new_member,
notifiable: membership,
triggered_by: current_champion
)
Rule: Max ONE unread new_member notification per community per champion.
When a new member joins:
new_member notification for this communityaggregation_count, update body and updated_at# 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
# In notification partial
<% if notification.aggregation_count > 1 %>
<%= notification.aggregation_count %> new members joined <%= notification.title %>
<% else %>
<%= notification.body %>
<% end %>
| 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 |
| 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) |
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] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
# 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
| 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 |
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]
# 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
# 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:
7.am.in_time_zone(champion.timezone)┌─────────────────────────────────────────────────────────────────────────┐
│ [Logo] Communities Directory �• 🔔• [Profile ▼] │
└─────────────────────────────────────────────────────────────────────────┘
Communities and Directory are text links💬 is messages icon with red dot if unread messages🔔 is bell icon with red dot if unread notificationsProfile ▼ is existing dropdown┌─────────────────────────────────────────────────────────────────────────┐
│ 🏠 🏘️ 👥 💬• 🔔• 👤 │
│ Home Communities Directory Messages Alerts Profile │
└─────────────────────────────────────────────────────────────────────────┘
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)
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>
# 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
cp_notifications migrationcp_notification_preferences migrationCp::Notification model with types enumCp::NotificationPreference modelCp::Championunread_notifications? method to Championnotifications_count (cached counter or query)Cp::NotificationService for creating notificationsCp::NotificationJob for async creationnew_memberCp::MessageService → new_messageCp::CommunityMembershipService → new_member, join_request_*CommunityCreationJob → community_suggestionnew_eventverification_*Cp::NotificationsController (index, mark_read, mark_all_read)Cp::NotificationMailer with digest templateCp::NotificationDigestJobCp::NotificationCleanupJob (delete old read notifications > 30 days)# 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
# 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
# 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
# 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
# test/mailers/cp/notification_mailer_test.rb
- digest includes all notification types
- digest groups by type
- digest respects frequency filter
- includes unsubscribe link
After completing Phase 1.16:
app/controllers/champions/roadmap_controller.rb — add Phase 1.16docs/features/champion_portal/NOTIFICATIONS.mddb/migrate/XXXXXX_create_cp_notifications.rbdb/migrate/XXXXXX_create_cp_notification_preferences.rbapp/models/cp/notification.rbapp/models/cp/notification_preference.rbapp/services/cp/notification_service.rbapp/jobs/cp/notification_job.rbapp/jobs/cp/notification_digest_job.rbapp/jobs/cp/notification_cleanup_job.rbapp/controllers/cp/notifications_controller.rbapp/views/cp/notifications/index.html.erbapp/views/cp/notifications/_dropdown.html.erbapp/views/cp/notifications/_notification.html.erbapp/mailers/cp/notification_mailer.rbapp/views/cp/notification_mailer/digest.html.erbapp/views/cp/notification_mailer/digest.text.erbapp/javascript/controllers/notification_dropdown_controller.jstest/models/cp/notification_test.rbtest/models/cp/notification_preference_test.rbtest/services/cp/notification_service_test.rbtest/controllers/cp/notifications_controller_test.rbtest/jobs/cp/notification_job_test.rbtest/jobs/cp/notification_digest_job_test.rbtest/mailers/cp/notification_mailer_test.rbapp/models/cp/champion.rb — add associations, unread_notifications? methodapp/views/layouts/cp/application.html.erb — add bell icon to headerapp/views/shared/cp/_mobile_nav.html.erb — add notification icon, message dotapp/views/cp/settings/_notifications.html.erb — add preferences UIapp/controllers/cp/settings_controller.rb — add preferences actionsapp/services/cp/message_service.rb — trigger new_message notificationapp/controllers/cp/communities_controller.rb — trigger new_member notificationconfig/routes.rb — add notification routes| 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