alumni_lookup

Phase 1.7: In-App Messaging

Champion Portal Development Phase 1.7

Estimated Effort: 5–7 days
Focus: Threaded In-App Messaging with Email Notifications

Prerequisites: Phase 1.6 complete (Dashboard, Directory & Metrics)

Related Documents:


Table of Contents

  1. Overview
  2. Why This Phase Exists
  3. Sub-Phases
  4. Schema Design
  5. Smart Notification System
  6. Scope
  7. Definition of Success
  8. Tests to Create
  9. Documentation Updates

1. Overview

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:


2. Why This Phase Exists

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

Why In-App vs Email Relay?

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.


3. Sub-Phases

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:


Sub-Phase 1.7.1: Database & Models ✅ COMPLETE

Status: Complete (December 2025)

Goal: Create the database schema and models for threaded messaging.

Deliverables:

Acceptance 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

Sub-Phase 1.7.2: Messaging UI ✅ COMPLETE (Simplified)

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:

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:

  1. Champion A views Champion B’s profile
  2. Clicks “Send Message” → floating panel opens with compose form
  3. Sends message → sees confirmation, thread view appears
  4. Champion B loads any page → sees “Messages” widget with badge “1”
  5. Champion B clicks widget → panel opens, sees conversation with unread dot
  6. Champion B clicks conversation → thread view, unread clears
  7. Champion B replies → message appears in thread
  8. Champion B clicks “↗️” → full-page messages view opens

Sub-Phase 1.7.3: Smart Notifications ✅ COMPLETE

Status: Complete (December 2025)

Goal: Email notifications that respect active conversations.

Logic: Only send email notification if:

  1. Message is > 5 minutes old
  2. Recipient hasn’t read the thread since the message was sent
  3. We haven’t already notified them about this thread in the last 5 minutes

Deliverables:

Implementation Notes:

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

4. Schema Design

Design Principles

Database Tables

# 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

Models

# 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

Champion Model Additions

# 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

5. Smart Notification System

Goals

  1. Don’t interrupt active conversations — If someone is replying back and forth, don’t spam them with emails
  2. Don’t miss important messages — If someone doesn’t check the portal, they still get notified
  3. Almost real-time feel — Nav badge updates on every page load

Implementation

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

Notification Flow

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

6. Scope

In Scope

Out of Scope (Future Phases)


7. Definition of Success

Functional Acceptance Criteria

Messaging:

Notifications:

Success Metrics

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)

8. Tests to Create

Model Tests

test/models/cp/message_thread_test.rb

test/models/cp/message_test.rb

test/models/cp/champion_test.rb (additions)

Controller Tests

test/controllers/cp/messages_controller_test.rb

Job Tests

test/jobs/message_notification_job_test.rb

Integration Tests

test/integration/cp/messaging_flow_test.rb


9. Documentation Updates

After Phase 1.7 completion, update:


Questions Resolved

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