Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/champion-portal/source/README.md.
Use these sources (includingCHRIST_CENTERED__IDENTITY_STATEMENT.md) when writing specs or user-facing copy. Prefer quoting/paraphrasing over inventing new language.
Status: Not Started
Estimated Effort: 1 week
Prerequisites: Phase 8 complete (Notifications & Digests Consolidation)
Related Docs:
- Phase 9 README
- Phase 8 README — Notification infrastructure
New Champions complete verification and then… silence. Without proactive guidance, Champions may:
A 6-email drip campaign that guides Champions through their first 30 days, with:
| Goal | Metric |
|---|---|
| Profile completion | 80%+ profiles complete within 30 days |
| Directory discovery | 70%+ Champions search directory within 14 days |
| Community engagement | 60%+ Champions join 2+ communities within 30 days |
| Event awareness | 50%+ Champions view events page within 30 days |
| Low unsubscribe rate | <10% opt-out of onboarding emails |
| # | Day | Purpose | Skip Condition | |
|---|---|---|---|---|
| 1 | Welcome | 0 | Account confirmed, getting started | — |
| 2 | Profile Nudge | 2 | Encourage profile completion | Profile 100% complete |
| 3 | Directory Intro | 5 | Help find Champions nearby | Has searched directory |
| 4 | Community Engagement | 10 | Introduce communities & discussions | Joined 3+ communities |
| 5 | Event Participation | 15 | Highlight events and RSVPs | Has RSVP’d to any event |
| 6 | 30-Day Check-In | 30 | Recap and encourage continued engagement | — |
All timing is based on champion_verified_at timestamp, NOT signup date. This ensures Champions only receive onboarding emails after they have full portal access.
# Day calculation
days_since_verified = (Date.current - champion.champion_verified_at.to_date).to_i
Each email (except Welcome and 30-Day) can be skipped if the Champion has already completed the target action:
# Skip conditions
def skip_profile_nudge?(champion)
champion.profile_completion_percentage == 100
end
def skip_directory_intro?(champion)
Cp::ActivityEvent.exists?(cp_champion: champion, event_type: 'directory_search')
end
def skip_community_engagement?(champion)
champion.communities.count >= 3
end
def skip_event_participation?(champion)
Cp::EventRsvp.exists?(champion: champion)
end
cp_onboarding_emailsTracks which emails have been sent to each Champion.
# Migration
class CreateCpOnboardingEmails < ActiveRecord::Migration[7.1]
def change
create_table :cp_onboarding_emails do |t|
t.references :cp_champion, null: false, foreign_key: true
t.string :email_type, null: false # welcome, profile_nudge, directory_intro, community_engagement, event_participation, thirty_day_checkin
t.datetime :sent_at
t.datetime :opened_at # Optional: tracking via pixel (if implemented)
t.datetime :clicked_at # Optional: tracking via link params
t.boolean :skipped, default: false # True if skip condition met
t.string :skip_reason # e.g., "profile_complete", "directory_searched"
t.timestamps
end
add_index :cp_onboarding_emails, [:cp_champion_id, :email_type], unique: true
add_index :cp_onboarding_emails, :email_type
add_index :cp_onboarding_emails, :sent_at
end
end
# app/models/cp/onboarding_email.rb
class Cp::OnboardingEmail < ApplicationRecord
belongs_to :champion, class_name: 'Cp::Champion', foreign_key: 'cp_champion_id'
EMAIL_TYPES = %w[
welcome
profile_nudge
directory_intro
community_engagement
event_participation
thirty_day_checkin
].freeze
validates :email_type, inclusion: { in: EMAIL_TYPES }
validates :cp_champion_id, uniqueness: { scope: :email_type }
scope :sent, -> { where.not(sent_at: nil) }
scope :pending, -> { where(sent_at: nil, skipped: false) }
scope :for_type, ->(type) { where(email_type: type) }
end
Cp::OnboardingEmailJobRuns daily via Heroku Scheduler to process onboarding emails.
# app/jobs/cp/onboarding_email_job.rb
class Cp::OnboardingEmailJob < ApplicationJob
queue_as :default
def perform
Rails.logger.info "[OnboardingEmailJob] Starting daily run"
process_welcome_emails
process_profile_nudge_emails
process_directory_intro_emails
process_community_engagement_emails
process_event_participation_emails
process_thirty_day_checkin_emails
Rails.logger.info "[OnboardingEmailJob] Complete"
end
private
def process_welcome_emails
# Champions verified today who haven't received welcome email
eligible_champions = Cp::Champion.champion_verified
.where(champion_verified_at: Date.current.all_day)
.where.not(id: Cp::OnboardingEmail.for_type('welcome').select(:cp_champion_id))
.where(onboarding_emails_enabled: true)
eligible_champions.find_each do |champion|
send_email(champion, 'welcome')
end
end
def process_profile_nudge_emails
# Champions verified 2 days ago with incomplete profiles
target_date = 2.days.ago.to_date
eligible_champions = Cp::Champion.champion_verified
.where(champion_verified_at: target_date.all_day)
.where.not(id: Cp::OnboardingEmail.for_type('profile_nudge').select(:cp_champion_id))
.where(onboarding_emails_enabled: true)
eligible_champions.find_each do |champion|
if champion.profile_completion_percentage == 100
skip_email(champion, 'profile_nudge', 'profile_complete')
else
send_email(champion, 'profile_nudge')
end
end
end
# Similar methods for other email types...
def send_email(champion, email_type)
record = Cp::OnboardingEmail.create!(
cp_champion_id: champion.id,
email_type: email_type,
sent_at: Time.current
)
Cp::OnboardingMailer.with(champion: champion).send(email_type).deliver_now
Rails.logger.info "[OnboardingEmailJob] Sent #{email_type} to Champion ##{champion.id}"
rescue => e
Rails.logger.error "[OnboardingEmailJob] Failed to send #{email_type} to Champion ##{champion.id}: #{e.message}"
end
def skip_email(champion, email_type, reason)
Cp::OnboardingEmail.create!(
cp_champion_id: champion.id,
email_type: email_type,
skipped: true,
skip_reason: reason
)
Rails.logger.info "[OnboardingEmailJob] Skipped #{email_type} for Champion ##{champion.id}: #{reason}"
end
end
Add to Heroku Scheduler (daily at 9 AM ET):
bin/rails runner "Cp::OnboardingEmailJob.perform_now"
Cp::OnboardingMailer# app/mailers/cp/onboarding_mailer.rb
class Cp::OnboardingMailer < ApplicationMailer
default from: 'Belmont Alumni Champions <champions@belmont.edu>'
layout 'cp_mailer'
def welcome
@champion = params[:champion]
@profile_url = cp_profile_url
@directory_url = cp_directory_url
mail(
to: @champion.email,
subject: "Welcome to the Champion Portal, #{@champion.display_first_name}! 🎉"
)
end
def profile_nudge
@champion = params[:champion]
@profile_edit_url = edit_cp_profile_url
@completion_percentage = @champion.profile_completion_percentage
mail(
to: @champion.email,
subject: "Your profile is #{@completion_percentage}% complete — finish it today!"
)
end
def directory_intro
@champion = params[:champion]
@directory_url = cp_directory_url
@nearby_count = Cp::Champion.in_district(@champion.district).count - 1
mail(
to: @champion.email,
subject: "#{@nearby_count} Champions are waiting to meet you"
)
end
def community_engagement
@champion = params[:champion]
@communities_url = cp_communities_url
@suggested_communities = @champion.suggested_communities.limit(3)
mail(
to: @champion.email,
subject: "Join the conversation — communities made for you"
)
end
def event_participation
@champion = params[:champion]
@events_url = cp_events_url
@upcoming_events = Cp::Event.upcoming.in_district(@champion.district).limit(3)
mail(
to: @champion.email,
subject: "Events happening near you — don't miss out!"
)
end
def thirty_day_checkin
@champion = params[:champion]
@dashboard_url = cp_dashboard_url
@stats = calculate_engagement_stats(@champion)
mail(
to: @champion.email,
subject: "Your first 30 days as a Champion — here's what you've accomplished"
)
end
private
def calculate_engagement_stats(champion)
{
communities_joined: champion.communities.count,
messages_sent: champion.sent_messages.count,
events_attended: champion.event_rsvps.count,
profile_views: champion.profile_views_count
}
end
end
<%# app/views/cp/onboarding_mailer/welcome.html.erb %>
<h1>Welcome to the Champion Portal, <%= @champion.display_first_name %>! 🎉</h1>
<p>
You're now part of a community of <%= Cp::Champion.champion_verified.count %> Alumni Champions
across <%= District.with_champions.count %> cities. We're so glad you're here.
</p>
<h2>Here's how to get started:</h2>
<ol>
<li>
<strong>Complete your profile</strong><br>
Add a photo and bio so other Champions can recognize you.
<%= link_to "Edit your profile →", @profile_url %>
</li>
<li>
<strong>Find Champions near you</strong><br>
See who else is in your area and say hello.
<%= link_to "Browse the directory →", @directory_url %>
</li>
<li>
<strong>Join the conversation</strong><br>
Check out your district's discussion board and introduce yourself.
</li>
</ol>
<p>
Questions? Just reply to this email — we're here to help.
</p>
<p>
Welcome to the family,<br>
<strong>The Belmont Alumni Team</strong>
</p>
Add an “Onboarding Progress” section to the Champion show page in Lookup Portal:
<%# app/views/champions/champions/show.html.erb %>
<div class="bg-white shadow rounded-lg p-6 mt-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Onboarding Progress</h3>
<% if @champion.onboarding_emails_enabled %>
<div class="space-y-3">
<% Cp::OnboardingEmail::EMAIL_TYPES.each do |type| %>
<% record = @champion.onboarding_emails.find_by(email_type: type) %>
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<div>
<span class="font-medium"><%= type.humanize %></span>
<% if record&.skipped %>
<span class="text-sm text-gray-500">(Skipped: <%= record.skip_reason %>)</span>
<% end %>
</div>
<div>
<% if record&.sent_at %>
<span class="text-green-600">✓ Sent <%= record.sent_at.strftime('%b %d') %></span>
<% elsif record&.skipped %>
<span class="text-gray-500">—</span>
<% else %>
<span class="text-gray-400">Pending</span>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-500">Champion has opted out of onboarding emails.</p>
<% end %>
</div>
Staff can manually resend any onboarding email:
# app/controllers/champions/champions_controller.rb
def resend_onboarding_email
@champion = Cp::Champion.find(params[:id])
email_type = params[:email_type]
Cp::OnboardingMailer.with(champion: @champion).send(email_type).deliver_now
redirect_to champions_champion_path(@champion),
notice: "#{email_type.humanize} email resent successfully."
end
onboarding_emails_enabledAdd to cp_champions table:
# Migration
add_column :cp_champions, :onboarding_emails_enabled, :boolean, default: true, null: false
Add to Champion Portal settings page (from Phase 8):
<%# app/views/cp/settings/_notifications.html.erb %>
<div class="space-y-4">
<h3 class="text-lg font-medium">Email Preferences</h3>
<!-- Existing notification preferences -->
<div class="border-t pt-4 mt-4">
<h4 class="font-medium text-gray-700">Onboarding Series</h4>
<p class="text-sm text-gray-500 mb-2">
Get helpful tips during your first 30 days as a Champion.
</p>
<label class="flex items-center gap-2">
<%= check_box_tag 'champion[onboarding_emails_enabled]', '1',
current_cp_champion.onboarding_emails_enabled,
class: 'rounded border-gray-300' %>
<span>Receive onboarding tips and reminders</span>
</label>
</div>
</div>
# test/models/cp/onboarding_email_test.rb
class Cp::OnboardingEmailTest < ActiveSupport::TestCase
test "validates email_type inclusion" do
email = Cp::OnboardingEmail.new(email_type: 'invalid')
assert_not email.valid?
assert_includes email.errors[:email_type], 'is not included in the list'
end
test "prevents duplicate email types per champion" do
champion = cp_champions(:sarah_champion)
Cp::OnboardingEmail.create!(cp_champion: champion, email_type: 'welcome', sent_at: Time.current)
duplicate = Cp::OnboardingEmail.new(cp_champion: champion, email_type: 'welcome')
assert_not duplicate.valid?
end
end
# test/jobs/cp/onboarding_email_job_test.rb
class Cp::OnboardingEmailJobTest < ActiveJob::TestCase
test "sends welcome email to newly verified champions" do
champion = cp_champions(:sarah_champion)
champion.update!(champion_verified_at: Time.current)
assert_emails 1 do
Cp::OnboardingEmailJob.perform_now
end
assert Cp::OnboardingEmail.exists?(cp_champion: champion, email_type: 'welcome')
end
test "skips profile nudge if profile complete" do
champion = cp_champions(:sarah_champion)
champion.update!(champion_verified_at: 2.days.ago)
# Ensure profile is 100% complete
assert_no_emails do
Cp::OnboardingEmailJob.perform_now
end
record = Cp::OnboardingEmail.find_by(cp_champion: champion, email_type: 'profile_nudge')
assert record.skipped
assert_equal 'profile_complete', record.skip_reason
end
test "respects onboarding_emails_enabled preference" do
champion = cp_champions(:sarah_champion)
champion.update!(champion_verified_at: Time.current, onboarding_emails_enabled: false)
assert_no_emails do
Cp::OnboardingEmailJob.perform_now
end
end
end
# test/mailers/cp/onboarding_mailer_test.rb
class Cp::OnboardingMailerTest < ActionMailer::TestCase
test "welcome email" do
champion = cp_champions(:sarah_champion)
email = Cp::OnboardingMailer.with(champion: champion).welcome
assert_equal "Welcome to the Champion Portal, #{champion.display_first_name}! 🎉", email.subject
assert_equal [champion.email], email.to
assert_match "Complete your profile", email.body.encoded
end
end
cp_onboarding_emails tableonboarding_emails_enabled to cp_championsCp::OnboardingEmail with validations and scopesCp::OnboardingEmailJob with skip logicCp::OnboardingMailer with 6 email methodsCp::OnboardingEmailCp::OnboardingEmailJob| Question | Status | Notes |
|---|---|---|
| Open tracking (pixel)? | Deferred | Nice-to-have; adds complexity |
| Click tracking? | Deferred | Can add UTM params for GA tracking |
| A/B testing subject lines? | Deferred | Not for initial implementation |
| Different timing by timezone? | Deferred | All emails send at same UTC time initially |