alumni_lookup

Sub-Phase 9.6: Onboarding Email Series

Canonical sources: Portal philosophy, posture, and language live in /docs/planning/champion-portal/source/README.md.
Use these sources (including CHRIST_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:


Table of Contents

  1. Overview
  2. Email Series Design
  3. Database Schema
  4. Background Job Implementation
  5. Email Templates
  6. Admin Interface
  7. User Preferences
  8. Testing Requirements
  9. Deliverables Checklist

1. Overview

Problem Statement

New Champions complete verification and then… silence. Without proactive guidance, Champions may:

Solution

A 6-email drip campaign that guides Champions through their first 30 days, with:

Goals

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

2. Email Series Design

Email Timeline

# Email 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

Timing Logic

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

Skip Logic

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

3. Database Schema

New Table: cp_onboarding_emails

Tracks 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

Email Types Enum

# 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

4. Background Job Implementation

Job: Cp::OnboardingEmailJob

Runs 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

Heroku Scheduler Setup

Add to Heroku Scheduler (daily at 9 AM ET):

bin/rails runner "Cp::OnboardingEmailJob.perform_now"

5. Email Templates

Mailer: 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

Email Template Example: Welcome

<%# 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>

6. Admin Interface

Lookup Portal: Champion Detail View

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>

Resend Email Action

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

7. User Preferences

New Preference: onboarding_emails_enabled

Add to cp_champions table:

# Migration
add_column :cp_champions, :onboarding_emails_enabled, :boolean, default: true, null: false

Settings UI Integration

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>

8. Testing Requirements

Unit Tests

# 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

Job Tests

# 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

Mailer Tests

# 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

9. Deliverables Checklist

Database

Background Job

Mailer

Admin Interface (Lookup Portal)

User Settings (Champion Portal)

Tests

Documentation


Questions to Resolve

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