Champion Portal Development Phase 1.7
Estimated Effort: 5–7 days
Focus: Threaded In-App Messaging with Email NotificationsPrerequisites: Phase 1.6 complete (Dashboard, Directory & Metrics)
Related Documents:
- ../../README.md — Champion Portal overview
- ../../features/06-MESSAGING.md — Full messaging spec
- ../phase-4/README.md — Real-time upgrades (builds on this)
- ../../JOBS-TO-BE-DONE.md — Job C6: Connect Directly
Phase 1.7 introduces in-app messaging between Champions with smart email notifications. This enables Champions to connect directly without sharing personal contact information, while preserving conversation history in the portal.
Key Design Decisions:
After Phase 1.7, Champions can:
From JOBS-TO-BE-DONE.md:
Job C6: Connect Directly
“When I find a Champion with shared interests or expertise, I want to reach out to them directly, so I can start a conversation without sharing my personal contact info publicly.”
Importance: 🔶 High
Current Satisfaction: ❌ Very Low
Opportunity Score: 🎯 High
The original Phase 1A planned email relay messaging, but analysis showed:
| Factor | Email Relay | In-App Messaging |
|---|---|---|
| Expected volume | 70-100 messages/day | Same |
| Message history | Lost in email | Preserved |
| User experience | Disjointed | Unified |
| Implementation effort | 2-3 days | 5-7 days |
| Throwaway code | Yes (if we build in-app later) | No |
| Moderation capability | None | Full visibility |
Decision: Build the final schema now with in-app messaging. Email notifications provide the “async” experience, while the portal provides history and moderation.
Phase 1.7 has 3 sub-phases:
| Sub-Phase | Name | Est. Time | Status |
|---|---|---|---|
| 1.7.1 | Database & Models | 1 day | ✅ Complete |
| 1.7.2 | Messaging UI | 2-3 days | ✅ Complete (simplified) |
| 1.7.3 | Smart Notifications | 1-2 days | ✅ Complete |
Implementation Notes:
MessageNotificationJob) for Heroku Schedulermessage_thread_started, message_sent)Status: Complete (December 2025)
Goal: Create the database schema and models for threaded messaging.
Deliverables:
cp_message_threads tablecp_message_thread_participants tablecp_messages tablecp_message_reactions table (for emoji reactions)Cp::MessageThread model with associationsCp::MessageThreadParticipant modelCp::Message model with validationsCp::MessageReaction model with toggle logicCp::MessagingService — Business logic layer for all messaging operationsAcceptance Test:
# Create a conversation
thread = Cp::MessageThread.create!(subject: "Hello!")
thread.participants.create!(champion: champion_a)
thread.participants.create!(champion: champion_b)
thread.messages.create!(sender: champion_a, body: "Hi there!")
# Check unread
champion_b.unread_message_count # => 1
Status: Complete (December 2025)
Implementation Note: We implemented a simplified full-page messaging UI instead of the floating widget panel. The floating widget is deferred to Phase 4 when we add real-time updates via Action Cable.
Goal: Build inbox, thread view, and emoji reactions.
Deliverables:
/messages)/messages/:id)Deferred to Phase 4 (Real-time):
Routes (Implemented):
# In champions subdomain routes
scope module: 'cp' do
resources :messages, only: [:index, :show, :create] do
member do
post :reactions, to: 'messages#toggle_reaction'
end
end
end
UI Design — Floating Widget Button (bottom-right of all pages):
┌─────────────────────┐
│ ✉️ Messages ●3 │
└─────────────────────┘
(● = unread count badge)
UI Design — Floating Panel (Inbox View):
┌─────────────────────────────────────────┐
│ Messages [✏️] [↗️] │ ← Compose / Expand
├─────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ │
│ │ [📷] Sarah Johnson · 42m│ │
│ │ ● We've done it several t... │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ [📷] Kristen Hayner · 1h │ │
│ │ You: we need to take note... │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ [📷] Mike Chen · 4h │ │
│ │ We really are haha │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ [📷] Emily Davis · 1d │ │
│ │ schmev7 reacted 😂 to your... │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
● = unread indicator
UI Design — Floating Panel (Thread View):
┌─────────────────────────────────────────┐
│ ← Sarah Johnson [↗️] [×] │
├─────────────────────────────────────────┤
│ │
│ ┌───────────────────────────┐ │
│ │ BNA is one of the │ │
│ │ approved airports! │ │
│ │ ❤️🧑 │ │
│ └───────────────────────────┘ │
│ 1:13 PM │
│ │
│ ┌───────────────────────────┐ │
│ │ [📷] Sarah │ │
│ │ We've done it several │ │
│ │ times 😊 │ │
│ │ ❤️🧑 │ │
│ └───────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ 😀│ Message... [→] │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
← = back to inbox
↗️ = expand to full page
× = close panel
UI Design — Full Page Messages (expanded view):
┌─────────────────────────────────────────────────────────────────────────────┐
│ chayner ▼ [✏️] │
├─────────────────────────────┬───────────────────────────────────────────────┤
│ 🔍 Search │ Sarah Johnson 📞 📹 ⓘ │
│ │ │
│ ┌───────────────────────┐ │ ┌─────────────────────────────────┐ │
│ │ [📷] Sarah Johnson │ │ │ BNA is one of the approved │ │
│ │ We've done it sev... │ │ │ airports! │ │
│ └───────────────────────┘ │ │ ❤️🧑 │ │
│ │ └─────────────────────────────────┘ │
│ ┌───────────────────────┐ │ 1:13 PM │
│ │ [📷] Kristen Hayner │ │ │
│ │ You: we need to... │ │ ┌─────────────────────────────────┐ │
│ └───────────────────────┘ │ │ [📷] Sarah │ │
│ │ │ We've done it several times 😊 │ │
│ ┌───────────────────────┐ │ │ ❤️🧑 │ │
│ │ [📷] Mike Chen │ │ └─────────────────────────────────┘ │
│ │ We really are haha │ │ │
│ └───────────────────────┘ │───────────────────────────────────────────── │
│ │ ┌─────────────────────────────────────────┐ │
│ │ │ 😀│ Message... [→] │ │
│ │ └─────────────────────────────────────────┘ │
└─────────────────────────────┴───────────────────────────────────────────────┘
Acceptance Test:
Status: Complete (December 2025)
Goal: Email notifications that respect active conversations.
Logic: Only send email notification if:
Deliverables:
MessageNotificationJob background jobMessageMailer with new message templatelast_read_at tracking per participantlast_notified_at tracking per participantImplementation Notes:
bin/rails runner "MessageNotificationJob.perform_now"muted: true)Email Template:
Subject: New message from Sarah Johnson
Hi [Recipient Name],
Sarah Johnson sent you a message on the Champion Portal:
────────────────────────────────────────────
"Hey! I saw you're in the Nashville area too. Would love
to connect about the upcoming alumni event."
────────────────────────────────────────────
Reply in the portal: [View Conversation]
────────────────────────────────────────────
Belmont Alumni Champion Portal
Update your notification preferences: [Settings]
Background Job:
# app/jobs/message_notification_job.rb
class MessageNotificationJob < ApplicationJob
queue_as :default
def perform
# Find participants with unread messages older than 5 minutes
# who haven't been notified recently
Cp::MessageThreadParticipant
.joins(message_thread: :messages)
.where("cp_messages.created_at < ?", 5.minutes.ago)
.where("cp_messages.created_at > cp_message_thread_participants.last_read_at OR cp_message_thread_participants.last_read_at IS NULL")
.where("cp_messages.sender_id != cp_message_thread_participants.champion_id")
.where("cp_message_thread_participants.last_notified_at < ? OR cp_message_thread_participants.last_notified_at IS NULL", 5.minutes.ago)
.distinct
.find_each do |participant|
# Get the most recent unread message in this thread
message = participant.message_thread.messages
.where("created_at > ?", participant.last_read_at || 100.years.ago)
.where.not(sender_id: participant.champion_id)
.order(created_at: :desc)
.first
next unless message
MessageMailer.new_message_notification(message, participant.champion).deliver_later
participant.touch(:last_notified_at)
end
end
end
Sidekiq Scheduled Job:
# config/schedule.yml or Heroku Scheduler
MessageNotificationJob: every 5 minutes
# db/migrate/XXXXXX_create_messaging_tables.rb
class CreateMessagingTables < ActiveRecord::Migration[7.1]
def change
# Conversation threads
create_table :cp_message_threads do |t|
t.string :subject # Optional subject line
t.datetime :last_message_at # For sorting inbox
t.integer :message_count, default: 0 # Counter cache
t.timestamps
end
# Participants in each thread
create_table :cp_message_thread_participants do |t|
t.references :message_thread, null: false,
foreign_key: { to_table: :cp_message_threads }
t.references :champion, null: false,
foreign_key: { to_table: :cp_champions }
t.datetime :last_read_at # When user last viewed thread
t.datetime :last_notified_at # When we last emailed them
t.boolean :muted, default: false # Future: mute notifications
t.boolean :archived, default: false # Future: archive thread
t.timestamps
t.index [:message_thread_id, :champion_id], unique: true
end
# Individual messages
create_table :cp_messages do |t|
t.references :message_thread, null: false,
foreign_key: { to_table: :cp_message_threads }
t.references :sender, null: false,
foreign_key: { to_table: :cp_champions }
t.text :body, null: false
t.datetime :deleted_at # Soft delete for moderation
t.timestamps
t.index [:message_thread_id, :created_at]
end
# Emoji reactions on messages
create_table :cp_message_reactions do |t|
t.references :message, null: false,
foreign_key: { to_table: :cp_messages }
t.references :champion, null: false,
foreign_key: { to_table: :cp_champions }
t.string :emoji, null: false # "❤️", "😂", "😮", "😢", "😠", "👍"
t.timestamps
t.index [:message_id, :champion_id], unique: true # One reaction per person
end
end
end
# app/models/cp/message_thread.rb
module Cp
class MessageThread < ApplicationRecord
self.table_name = 'cp_message_threads'
has_many :participants, class_name: 'Cp::MessageThreadParticipant',
foreign_key: :message_thread_id, dependent: :destroy
has_many :champions, through: :participants
has_many :messages, class_name: 'Cp::Message',
foreign_key: :message_thread_id, dependent: :destroy
scope :for_champion, ->(champion) {
joins(:participants).where(cp_message_thread_participants: { champion_id: champion.id })
}
scope :active, -> { where(archived: false) }
scope :recent, -> { order(last_message_at: :desc) }
def other_participants(champion)
participants.where.not(champion_id: champion.id)
end
def unread_for?(champion)
participant = participants.find_by(champion: champion)
return true if participant.nil? || participant.last_read_at.nil?
messages.where("created_at > ?", participant.last_read_at)
.where.not(sender_id: champion.id).exists?
end
def mark_read!(champion)
participants.find_by(champion: champion)&.update!(last_read_at: Time.current)
end
end
end
# app/models/cp/message_thread_participant.rb
module Cp
class MessageThreadParticipant < ApplicationRecord
self.table_name = 'cp_message_thread_participants'
belongs_to :message_thread, class_name: 'Cp::MessageThread'
belongs_to :champion, class_name: 'Cp::Champion'
scope :unread, -> {
joins(message_thread: :messages)
.where("cp_messages.created_at > cp_message_thread_participants.last_read_at OR cp_message_thread_participants.last_read_at IS NULL")
.where("cp_messages.sender_id != cp_message_thread_participants.champion_id")
.distinct
}
end
end
# app/models/cp/message.rb
module Cp
class Message < ApplicationRecord
self.table_name = 'cp_messages'
belongs_to :message_thread, class_name: 'Cp::MessageThread',
counter_cache: :message_count, touch: :last_message_at
belongs_to :sender, class_name: 'Cp::Champion'
has_many :reactions, class_name: 'Cp::MessageReaction',
foreign_key: :message_id, dependent: :destroy
validates :body, presence: true, length: { minimum: 1, maximum: 5000 }
scope :visible, -> { where(deleted_at: nil) }
scope :chronological, -> { order(created_at: :asc) }
def soft_delete!
update!(deleted_at: Time.current)
end
end
end
# app/models/cp/message_reaction.rb
module Cp
class MessageReaction < ApplicationRecord
self.table_name = 'cp_message_reactions'
ALLOWED_EMOJI = %w[❤️ 😂 😮 😢 😠 👍].freeze
belongs_to :message, class_name: 'Cp::Message'
belongs_to :champion, class_name: 'Cp::Champion'
validates :emoji, presence: true, inclusion: { in: ALLOWED_EMOJI }
validates :champion_id, uniqueness: { scope: :message_id,
message: "can only react once per message" }
# Toggle reaction: if same emoji exists, remove it; otherwise set it
def self.toggle!(message, champion, emoji)
existing = find_by(message: message, champion: champion)
if existing&.emoji == emoji
existing.destroy!
nil
else
existing&.destroy!
create!(message: message, champion: champion, emoji: emoji)
end
end
end
end
# app/models/cp/champion.rb (additions)
has_many :message_thread_participants, class_name: 'Cp::MessageThreadParticipant',
foreign_key: :champion_id
has_many :message_threads, through: :message_thread_participants
has_many :sent_messages, class_name: 'Cp::Message', foreign_key: :sender_id
def unread_message_count
message_thread_participants.unread.count
end
def can_message?(other_champion)
# Both must be verified, can't message self
champion_verified? && other_champion.champion_verified? && id != other_champion.id
end
On Page Load (Header):
# In application layout or helper
<% if current_champion %>
<span class="unread-badge"><%= current_champion.unread_message_count %></span>
<% end %>
On Thread View:
# In messages#show
@thread.mark_read!(current_champion)
Background Job (Every 5 Minutes):
# Finds messages that:
# - Are > 5 minutes old
# - Haven't been read by recipient
# - Recipient hasn't been notified in last 5 minutes
# Then sends email notification
Champion A sends message
↓
Message saved to database
↓
Champion B loads any page → sees badge increment
↓
If Champion B reads thread within 5 min → no email
↓
If Champion B doesn't read → background job sends email at 5 min mark
↓
Champion B clicks email link → lands in thread → badge clears
Messaging:
Notifications:
| Metric | Target |
|---|---|
| Messages sent per week (pilot) | 50+ |
| Average response time | < 24 hours |
| % of messages that trigger email | < 50% (meaning people are using the portal) |
test/models/cp/message_thread_test.rb
test/models/cp/message_test.rb
test/models/cp/champion_test.rb (additions)
test/controllers/cp/messages_controller_test.rb
test/jobs/message_notification_job_test.rb
test/integration/cp/messaging_flow_test.rb
After Phase 1.7 completion, update:
docs/CHANGELOG.md — Add messaging featuresdocs/development/MODEL_RELATIONSHIPS.md — Add message modelsdocs/planning/champion-portal/features/06-MESSAGING.md — Mark Phase 1.7 completeapp/controllers/champions/roadmap_controller.rb — Add 1.7 subphases| Question | Decision |
|---|---|
| Real-time vs refresh? | Refresh for now; Action Cable in Phase 4 |
| Email notification timing? | 5-minute delay if unread |
| Group messaging? | Schema supports it; 1:1 only at launch |
| Message retention? | Forever (soft delete for moderation) |
| Rate limiting? | 10 messages/day per sender |