Champion Portal Development Sub-Phase 9.2
Status: ✅ Complete
Completed: February 2026
Estimated Effort: 1.5 weeks
Prerequisites: Sub-Phase 9.1 Complete, Phase 3 (Discussion Boards) CompleteRelated Documents:
- README.md — Phase 9 Overview
- 9.1-onboarding-flow.md — Enhanced wizard
- ../phase-3/README.md — Discussion boards infrastructure
Models:
Cp::SeededQuestion — Staff-authored question templates with targeting (global, community_type, affinity_category), status workflow, priority weightingCp::SeededQuestionExposure — Tracks question postings with community/board_post associationsServices:
Cp::SeededQuestionSelector — Selection algorithm with eligibility checks, weighted random selection, rotation cooldown (30 days)Cp::SeededQuestionPublisher — Transaction-wrapped publishing with interpolation and exposure trackingCp::QuestionInterpolator — Template variable interpolation ({community_name}, {current_season}, etc.)Key Constants:
ROTATION_DAYS = 30 — Cooldown before re-posting same question to same communityMAX_ACTIVE_PER_COMMUNITY = 2 — Limit on concurrent seeded questions per boardORGANIC_ACTIVITY_DAYS = 7 — Skip communities with recent organic activityMIN_MEMBER_COUNT = 3 — Skip communities with too few membersFiles Created:
app/models/cp/seeded_question.rb (236 lines)app/models/cp/seeded_question_exposure.rb (68 lines)app/services/cp/seeded_question_selector.rb (197 lines)app/services/cp/seeded_question_publisher.rb (87 lines)app/services/cp/question_interpolator.rbdb/migrate/*_create_cp_seeded_questions.rbdb/migrate/*_create_cp_seeded_question_exposures.rbImplementation Note:
:body field, but implementation correctly uses body_template (database column name)Cp::SeedQuestionsJob — Daily automatic posting job (needs metrics integration)Sub-Phase 9.2 creates a seeded discussion question system that populates community boards with engaging starter questions. This transforms empty boards from ghost towns into active conversation spaces.
A community with zero posts feels dead. A community with one good question feels alive.
New Champions who land on an empty board have no reason to return. Seeded questions provide social proof that conversations happen here, even before organic activity takes off.
When a Champion joins a new community:
┌─────────────────────────────────────────────────────────────────────────┐
│ 🎵 Music Business Discussion Board │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ │
│ No discussions yet. │
│ │
│ Be the first to start a conversation! │
│ │
│ [Start Discussion] │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Result: Champion doesn’t post, doesn’t return, community never grows.
┌─────────────────────────────────────────────────────────────────────────┐
│ 🎵 Music Business Discussion Board │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 📌 From the Engagement Team │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ "What's the best career advice you've received in the │ │
│ │ music industry?" │ │
│ │ │ │
│ │ 💬 3 replies · 12 reactions [Join Discussion] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Recent Discussions │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ... │ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Result: Champion sees activity, has a low-friction way to participate, may return.
┌─────────────────────────────────────────────────────────────────────────┐
│ SEEDED QUESTION LIFECYCLE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [Create Question] │
│ ↓ │
│ [Active in Pool] ─────→ [Selected for Community] ────→ [Published] │
│ ↑ ↓ ↓ │
│ │ [Exposure Tracked] [Responses] │
│ │ ↓ ↓ │
│ └─────────── [Rotation Cycle] ←──── [Archived/Replaced] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Cp::SeededQuestion# app/models/cp/seeded_question.rb
class Cp::SeededQuestion < ApplicationRecord
# Enums
enum :status, { draft: 0, active: 1, paused: 2, archived: 3 }
enum :target_type, {
all_communities: 0, # Any community
district: 1, # District communities only
college: 2, # College communities only
major: 3, # Major communities only
affinity: 4, # Affinity communities only
industry: 5, # Industry communities only
affinity_category: 6 # Specific affinity category (Greek Life, Athletics, etc.)
}
# Valid affinity categories (from affinities.category column)
AFFINITY_CATEGORIES = [
'Athletics', 'Campus Life', 'Geographic', 'Greek Life',
'Instrumental Ensembles', 'Post Graduation', 'Spiritual Life', 'Vocal Ensembles'
].freeze
# Validations
validates :title, presence: true, length: { maximum: 280 }
validates :body, length: { maximum: 2000 }
validates :target_type, presence: true
validates :affinity_category, presence: true, inclusion: { in: AFFINITY_CATEGORIES },
if: -> { target_type == 'affinity_category' }
# Scopes
scope :active_for_type, ->(type) {
active.where(target_type: [type, 'all_communities'])
}
scope :for_affinity_category, ->(category) {
active.where(target_type: 'affinity_category', affinity_category: category)
}
scope :not_recently_used_in, ->(community, days: 30) {
where.not(id: community.seeded_question_exposures
.where('created_at > ?', days.ago)
.select(:seeded_question_id))
}
# Associations
has_many :exposures, class_name: 'Cp::SeededQuestionExposure', dependent: :destroy
has_many :exposed_communities, through: :exposures, source: :community
end
Cp::SeededQuestionExposureTracks which questions have been shown in which communities:
# app/models/cp/seeded_question_exposure.rb
class Cp::SeededQuestionExposure < ApplicationRecord
belongs_to :seeded_question, class_name: 'Cp::SeededQuestion'
belongs_to :community, class_name: 'Cp::Community'
belongs_to :board_post, class_name: 'Cp::BoardPost', optional: true
validates :seeded_question_id, uniqueness: { scope: :community_id }
scope :active, -> { where(archived_at: nil) }
scope :with_engagement, -> { where('reply_count > 0 OR reaction_count > 0') }
end
# db/migrate/YYYYMMDDHHMMSS_create_cp_seeded_questions.rb
class CreateCpSeededQuestions < ActiveRecord::Migration[7.1]
def change
create_table :cp_seeded_questions do |t|
t.string :title, null: false
t.text :body
t.integer :status, default: 0, null: false
t.integer :target_type, default: 0, null: false
t.string :affinity_category # e.g., 'Greek Life', 'Athletics', 'Vocal Ensembles'
t.integer :priority, default: 0
t.datetime :active_from
t.datetime :active_until
t.jsonb :metadata, default: {}
t.timestamps
end
add_index :cp_seeded_questions, :status
add_index :cp_seeded_questions, :target_type
add_index :cp_seeded_questions, :affinity_category
create_table :cp_seeded_question_exposures do |t|
t.references :seeded_question, null: false, foreign_key: { to_table: :cp_seeded_questions }
t.references :community, null: false, foreign_key: { to_table: :cp_communities }
t.references :board_post, foreign_key: { to_table: :cp_board_posts }
t.integer :reply_count, default: 0
t.integer :reaction_count, default: 0
t.datetime :archived_at
t.timestamps
end
add_index :cp_seeded_question_exposures,
[:seeded_question_id, :community_id],
unique: true,
name: 'idx_seeded_exposures_unique'
end
end
Cp::SeededQuestionSelector# app/services/cp/seeded_question_selector.rb
class Cp::SeededQuestionSelector
ROTATION_DAYS = 30
MAX_ACTIVE_PER_COMMUNITY = 2
def initialize(community)
@community = community
end
def call
return nil if sufficient_organic_activity?
return nil if at_max_active_questions?
select_best_question
end
private
def sufficient_organic_activity?
# Don't seed if community has recent organic posts
@community.board_posts
.where(seeded_question_exposure_id: nil)
.where('created_at > ?', 7.days.ago)
.exists?
end
def at_max_active_questions?
@community.seeded_question_exposures.active.count >= MAX_ACTIVE_PER_COMMUNITY
end
def select_best_question
candidates = candidate_questions
return nil if candidates.empty?
# Weighted random selection favoring higher priority and freshness
weighted_sample(candidates)
end
def candidate_questions
base = Cp::SeededQuestion
.active
.not_recently_used_in(@community, days: ROTATION_DAYS)
.where('active_from IS NULL OR active_from <= ?', Time.current)
.where('active_until IS NULL OR active_until >= ?', Time.current)
# Filter by community type
case @community.community_type
when 'district'
base.active_for_type('district')
when 'college'
base.active_for_type('college')
when 'major'
base.active_for_type('major')
when 'affinity'
category = @community.affinity_category
base.active_for_type('affinity')
.or(base.for_affinity_category(category))
when 'industry'
base.active_for_type('industry')
else
base.active_for_type('all_communities')
end
end
def weighted_sample(questions)
# Higher priority = more weight
# More recently created = more weight (freshness bonus)
weights = questions.map do |q|
priority_weight = (q.priority + 1) * 10
freshness_weight = [1, 30 - ((Time.current - q.created_at) / 1.day).to_i].max
priority_weight + freshness_weight
end
total = weights.sum
random = rand * total
cumulative = 0
questions.each_with_index do |q, i|
cumulative += weights[i]
return q if random <= cumulative
end
questions.last
end
end
Questions are selected and published:
Cp::SeedQuestionsJob runs at 6 AM (see below)CRITICAL: The system AUTOMATICALLY posts seeded discussions to communities lacking recent activity. This is not admin-triggered—it’s a daily automated process.
Cp::SeedQuestionsJob# app/jobs/cp/seed_questions_job.rb
class Cp::SeedQuestionsJob < ApplicationJob
queue_as :low
# Run daily at 6 AM via Heroku Scheduler or sidekiq-cron
def perform
eligible_communities.find_each do |community|
# Skip if community has had any organic OR seeded discussion in past X days
next if recent_activity?(community)
# Select and post a seeded question
question = Cp::SeededQuestionSelector.new(community).select
next unless question
Cp::SeededQuestionPublisher.new(question, community).publish!
Rails.logger.info "[SeedQuestionsJob] Posted seeded question #{question.id} to #{community.name}"
end
end
private
def eligible_communities
# Only communities that have discussions enabled and meet activation threshold
Cp::Community
.where(discussions_enabled: true)
.where('member_count >= ?', Cp::Community::ACTIVATION_THRESHOLD)
end
def recent_activity?(community)
# Check for ANY recent post (organic OR seeded) in the configured window
community.board_posts
.where('created_at > ?', activity_threshold_days.days.ago)
.exists?
end
def activity_threshold_days
# Configurable: how many days without activity before auto-seeding
ENV.fetch('SEEDED_QUESTION_ACTIVITY_THRESHOLD_DAYS', 7).to_i
end
end
| Setting | Default | Description |
|---|---|---|
SEEDED_QUESTION_ACTIVITY_THRESHOLD_DAYS |
7 | Days without any post before auto-seeding |
SEEDED_QUESTION_MAX_ACTIVE_PER_COMMUNITY |
2 | Maximum active seeded questions per community |
SeededQuestionExposure)# Add to Heroku Scheduler
heroku addons:create scheduler:standard --app alumni-lookup
# Configure daily job
heroku addons:open scheduler --app alumni-lookup
# Add: "bin/rails runner 'Cp::SeedQuestionsJob.perform_now'" at 6:00 AM UTC
# Rails console: Check seeded question activity
Cp::SeededQuestionExposure.where('created_at > ?', 7.days.ago).count
# => 23 (seeded questions posted in last week)
# Communities without recent activity
Cp::Community.where(discussions_enabled: true)
.where.not(id: Cp::BoardPost.where('created_at > ?', 7.days.ago)
.select(:community_id))
.count
# => 12 (communities that would receive seeded questions)
| Variable | Description | Example |
|---|---|---|
{community_name} |
Community display name | “Nashville Champions” |
{community_type} |
Humanized type | “your city”, “your college” |
{member_count} |
Current member count | “23” |
{current_month} |
Current month name | “January” |
{current_season} |
Current season | “winter” |
{current_year} |
Current year | “2026” |
Cp::QuestionInterpolator# app/services/cp/question_interpolator.rb
class Cp::QuestionInterpolator
SEASON_MONTHS = {
'spring' => [3, 4, 5],
'summer' => [6, 7, 8],
'fall' => [9, 10, 11],
'winter' => [12, 1, 2]
}
def initialize(question, community)
@question = question
@community = community
end
def interpolate_title
interpolate(@question.title)
end
def interpolate_body
return nil if @question.body.blank?
interpolate(@question.body)
end
private
def interpolate(text)
text
.gsub('{community_name}', @community.display_name)
.gsub('{community_type}', humanized_type)
.gsub('{member_count}', @community.members_count.to_s)
.gsub('{current_month}', Date.current.strftime('%B'))
.gsub('{current_season}', current_season)
.gsub('{current_year}', Date.current.year.to_s)
end
def humanized_type
case @community.community_type
when 'district' then 'your city'
when 'college' then 'your college'
when 'major' then 'your major'
when 'affinity' then 'your community'
when 'industry' then 'your industry'
else 'this community'
end
end
def current_season
month = Date.current.month
SEASON_MONTHS.find { |_, months| months.include?(month) }&.first || 'spring'
end
end
| Template | Interpolated (Nashville) |
|---|---|
| “What brought you to {community_name}?” | “What brought you to Nashville Champions?” |
| “What’s your favorite {current_season} tradition in {community_type}?” | “What’s your favorite winter tradition in your city?” |
| “The {community_name} community has {member_count} members. How did you first connect with Belmont?” | “The Nashville Champions community has 23 members. How did you first connect with Belmont?” |
Creating unique questions for each of 50+ affinity communities is impractical. But generic questions feel hollow.
The affinities table already has a category column that groups affinities into logical types. We use this existing data instead of creating duplicate grouping logic:
Category (from affinities.category) |
Example Affinities | Example Question |
|---|---|---|
Greek Life |
Phi Mu, Chi Omega, Sigma Chi | “What’s your favorite memory from Greek Week?” |
Athletics |
SAAC, Baseball Alumni, Soccer Alumni | “How did being a Bruin athlete shape your career?” |
Instrumental Ensembles |
Jazz Band, Orchestra, Wind Ensemble | “What’s the most memorable performance you participated in?” |
Vocal Ensembles |
Joyful Sound, Musical Theatre Ensemble | “What’s the most memorable performance you participated in?” |
Campus Life |
Bruin Buddies, SGA, Towering Traditions | “How did your campus involvement shape who you are?” |
Spiritual Life |
Belmont Missions, Chapel Band | “What service or faith experience had the biggest impact on you?” |
Geographic |
Nashville Alumni, Atlanta Alumni | “What brought you to this city?” |
Post Graduation |
Young Alumni, Senior Alumni | “How has your perspective on Belmont changed over time?” |
Note: These 8 categories come directly from
SELECT DISTINCT category FROM affinities— no custom mapping needed.
The Cp::Community model already has an #affinity method that returns the ::Affinity record. We simply access the category:
# app/models/cp/community.rb
class Cp::Community < ApplicationRecord
# Existing method — returns the Affinity record for affinity-type communities
def affinity
return nil unless community_type == 'affinity'
::Affinity.find_by(affinity_code: affinity_code)
end
# New convenience method for seeded questions
def affinity_category
affinity&.category
end
end
Why this approach:
affinity_autocomplete_controller.js line 73 displaying (${affinity.category}))When a seeded question is selected for a community:
# app/services/cp/seeded_question_publisher.rb
class Cp::SeededQuestionPublisher
def initialize(question, community)
@question = question
@community = community
end
def call
return if already_exposed?
ActiveRecord::Base.transaction do
post = create_board_post
create_exposure(post)
post
end
end
private
def already_exposed?
Cp::SeededQuestionExposure.exists?(
seeded_question: @question,
community: @community
)
end
def create_board_post
interpolator = Cp::QuestionInterpolator.new(@question, @community)
Cp::BoardPost.create!(
community: @community,
author_type: 'Staff', # Signals this is "Engagement Team"
author_id: nil,
title: interpolator.interpolate_title,
body: interpolator.interpolate_body,
pinned: false, # Seeded questions aren't pinned by default
seeded: true # Flag to identify seeded posts
)
end
def create_exposure(post)
Cp::SeededQuestionExposure.create!(
seeded_question: @question,
community: @community,
board_post: post
)
end
end
# Add to Cp::BoardPost
class Cp::BoardPost < ApplicationRecord
# Existing code...
# Add column
attribute :seeded, :boolean, default: false
belongs_to :seeded_question_exposure,
class_name: 'Cp::SeededQuestionExposure',
optional: true
scope :seeded, -> { where(seeded: true) }
scope :organic, -> { where(seeded: false) }
# Existing author_display_name already returns "Engagement Team" for Staff author_type
end
Seeded questions display like regular posts with visual distinction:
<% if post.seeded? %>
<div class="bg-blue-50 border-l-4 border-belmontblue rounded-lg p-4">
<div class="flex items-center gap-2 text-sm text-belmontblue mb-2">
<%= heroicon "megaphone", variant: :outline, options: { class: "w-4 h-4" } %>
<span class="font-medium">From the Engagement Team</span>
</div>
<!-- Post content -->
</div>
<% else %>
<!-- Regular post display -->
<% end %>
┌─────────────────────────────────────────────────────────────────────────┐
│ 🎵 Music Business Discussion Board │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 📢 From the Engagement Team │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ "What's the best career advice you've received in the music │ │
│ │ industry?" │ │
│ │ │ │
│ │ Share the wisdom that helped you navigate your path! │ │
│ │ │ │
│ │ 💬 7 replies · ❤️ 15 · 👍 8 · 🎉 3 [Reply] [React] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Recent Discussions ────────────────────────────────────────────────── │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Sarah Champion 2 hours │ │
│ │ "Anyone else headed to Summer NAMM this year?" │ │
│ │ 💬 3 replies · ❤️ 5 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Cp::SeededQuestion model with migrationCp::SeededQuestionExposure model with migrationseeded column to cp_board_postsCp::SeededQuestionSelectorCp::QuestionInterpolatorCp::SeededQuestionPublisherCp::SeedQuestionsJobFile: test/models/cp/seeded_question_test.rb
class Cp::SeededQuestionTest < ActiveSupport::TestCase
test "validates title presence" do
question = Cp::SeededQuestion.new(title: nil)
assert_not question.valid?
assert_includes question.errors[:title], "can't be blank"
end
test "validates affinity_category when target_type is affinity_category" do
question = Cp::SeededQuestion.new(
title: "Test",
target_type: 'affinity_category',
affinity_category: nil
)
assert_not question.valid?
assert_includes question.errors[:affinity_category], "can't be blank"
end
test "active_for_type scope includes all_communities" do
all_q = create(:seeded_question, target_type: 'all_communities')
district_q = create(:seeded_question, target_type: 'district')
college_q = create(:seeded_question, target_type: 'college')
results = Cp::SeededQuestion.active_for_type('district')
assert_includes results, all_q
assert_includes results, district_q
assert_not_includes results, college_q
end
end
File: test/services/cp/seeded_question_selector_test.rb
class Cp::SeededQuestionSelectorTest < ActiveSupport::TestCase
setup do
@community = cp_communities(:nashville_district)
@question = create(:seeded_question, target_type: 'district', status: 'active')
end
test "selects question for community with matching type" do
selector = Cp::SeededQuestionSelector.new(@community)
result = selector.call
assert_equal @question, result
end
test "returns nil when community has recent organic posts" do
create(:board_post, community: @community, seeded: false, created_at: 1.day.ago)
selector = Cp::SeededQuestionSelector.new(@community)
assert_nil selector.call
end
test "excludes recently used questions" do
create(:seeded_question_exposure,
seeded_question: @question,
community: @community,
created_at: 10.days.ago)
selector = Cp::SeededQuestionSelector.new(@community)
assert_nil selector.call # Only question was recently used
end
end
File: test/services/cp/question_interpolator_test.rb
class Cp::QuestionInterpolatorTest < ActiveSupport::TestCase
test "interpolates community_name" do
question = Cp::SeededQuestion.new(title: "Welcome to {community_name}!")
community = cp_communities(:nashville_district)
interpolator = Cp::QuestionInterpolator.new(question, community)
assert_equal "Welcome to Nashville Champions!", interpolator.interpolate_title
end
test "interpolates current_season correctly" do
question = Cp::SeededQuestion.new(title: "What's your {current_season} tradition?")
community = cp_communities(:nashville_district)
travel_to Date.new(2026, 1, 15) do
interpolator = Cp::QuestionInterpolator.new(question, community)
assert_equal "What's your winter tradition?", interpolator.interpolate_title
end
end
end
File: test/integration/cp/seeded_questions_flow_test.rb
class Cp::SeededQuestionsFlowTest < ActionDispatch::IntegrationTest
test "seeded question appears on community board" do
sign_in cp_champions(:sarah_champion)
community = cp_communities(:music_business)
# Publish a seeded question
question = create(:seeded_question,
title: "What brought you to {community_name}?",
target_type: 'major',
status: 'active')
Cp::SeededQuestionPublisher.new(question, community).call
get cp_community_board_path(community)
assert_response :success
assert_select ".seeded-question", text: /What brought you to Music Business/
assert_select ".engagement-team-badge", text: "From the Engagement Team"
end
end
Initial questions to seed the system:
To be updated after implementation
To be updated after implementation
Document created: January 2026