Champion Portal Development Phase 8
Purpose: Consolidate and standardize the notification system so Champions receive:
- ONE consolidated Daily Digest per user per day
- ONE consolidated Weekly Digest per user per week
- Consistent immediate-notification email templates
- Reliable Heroku Scheduler setup for digest delivery
- An onboarding email series that teaches Champions principles, then points to features
Status: Planning Last Updated: January 2026
Related Documents:
- Phase 1.16: Notifications — Current notification system implementation
- SCHEDULERS.md — Current Heroku Scheduler configuration
- BACKLOG.md — Deferred notification features
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
| 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 |
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
| 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) |
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 |
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) |
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.
Current implementation: Single ChampionSignupMailer.welcome_email sent when:
No onboarding series exists. There is no drip campaign or multi-email sequence.
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):
Cp::WeeklyDigestJob: return unless Time.zone.today.monday?Files Modified:
app/jobs/cp/weekly_digest_job.rb — Added Monday guardtest/jobs/cp/weekly_digest_job_test.rb — Added day-of-week testsONE digest email per frequency per user, not one per feature.
A Champion should receive at most:
📬 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📬 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| 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 |
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 |
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:
| 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. │
│ │
└─────────────────────────────────────────────────────────────────────┘
| 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 |
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 |
| 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 |
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) |
Daily Digest Changes:
Weekly Digest Changes:
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 |
✅ 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
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.
# 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')"
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 |
| 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 |
Teach principles first, then point to features.
The onboarding series should:
Only one email exists: ChampionSignupMailer.welcome_email
Sent when: Champion completes signup quiz or is approved by staff
| Day | 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 |
# 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
# 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
# Add to Heroku Scheduler — Every hour
bin/rails runner "Cp::OnboardingEmailJob.perform_later"
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
| 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 |
| 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 |
| 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 |
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:
app/views/cp/notification_mailer/verification_pending_notification.html.erb (+ .text.erb)app/views/cp/notification_mailer/verification_approved_notification.html.erb (+ .text.erb)app/views/cp/notification_mailer/support_reply_notification.html.erb (+ .text.erb)app/views/cp/notification_mailer/community_leader_assigned_notification.html.erb (+ .text.erb)app/views/cp/champion_mailer/confirmation_instructions.html.erb (branded)app/mailers/cp/champion_mailer.rb (added build_full_url helper)app/views/cp/settings/_section_notifications.html.erb (Discussions group, CL-only)app/jobs/cp/weekly_digest_job.rb (Monday guard)Tests: 2551 runs, 0 failures, 0 errors
Deferred to Phase 8.5+:
cp_onboarding_emails table and Cp::OnboardingMailer| 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 |
join_request_received, support_reply, community_leader_assigned)post_reply, comment_reply, post_reaction, comment_reaction to helperNotificationDigestJob to include unread messagesdaily_digest.html.erb with new sectionsweekly_digest.html.erb with events/stats sectionsHeroku 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:
Cp::WeeklyDigestJob — return unless Time.zone.today.monday?Champion Digests:
verification_pending templateverification_approved templatesupport_reply templatecommunity_leader_assigned templatecp_onboarding_emails table and modelCp::OnboardingMailer with 6 email methodsCp::OnboardingEmailJobweb-push gempush_subscriptions table and modelpublic/service-worker.js)public/manifest.json)WebPushService for sending pushesWebPushJob for async deliveryStaffNotification creationScope: Lookup Portal staff only (single implementation)
Focus: Immediate notifications for time-sensitive actions
Approach: Self-hosted using Web Push API with VAPID keys
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:
| 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” |
┌─────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────┘
push_subscriptions Tablecreate_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
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
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"
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);
}
})
);
});
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"
}
]
}
# 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
# 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
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
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)))
}
}
# 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
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>
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 |
# 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
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
| 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 |
Once Lookup Portal push notifications are working, the same infrastructure can be extended to Champion Portal:
has_many :push_subscriptions, as: :subscribable to Cp::ChampionCp::PushSubscriptionsController with champion authCp::NotificationService.create() for immediate typesImmediate notification types for Champion Portal:
join_request_approvedjoin_request_deniedverification_approvedsupport_replynew_message (if preference is immediate)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
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
| 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 |
| 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.