Status: In Progress
Estimated Effort: 4-6 weeks
Prerequisites: Phase 4 Complete (Mobile-First Interface Cleanup)Related Documents:
- ../README.md — Phase Index
- ../../JOBS-TO-BE-DONE.md — User needs
Phase 5 adds final features required for MVP launch — capabilities that enhance the core Champion experience before opening to all users.
| Feature | Description |
|---|---|
| User State Experience Alignment | Intentional UX for anonymous, email-verified, and champion-verified states |
| Faculty/Staff Support | Belmont faculty/staff can join as Champions with manual approval |
| Almost Alumni Support | Current students with anticipated graduation dates |
| Friending/Favoriting | Save other Champions for quick access |
| Alumni Matching | Help new signups connect with their alumni record |
| Photo Albums | Champions and events can have photo galleries |
Phase 5 completion = MVP ready. After this phase, the Champion Portal can soft launch to initial users.
Anticipated needs before broader launch:
Goal: Create intentional, differentiated UX for three distinct user states with consistent messaging and appropriate feature gating.
→ See 5.1-user-experiences.md for full specification.
Summary: | User State | Can Access | Experience Theme | |————|————|——————| | Anonymous | Landing page, public event/post URLs | “Welcome—here’s what awaits” | | Email Verified | Dashboard (limited), profile edit | “You’re almost there—verification in progress” | | Champion Verified | Full portal access | “Welcome home—your community” |
Key Deliverables:
Goal: Allow current students (Almost Alumni) and Belmont faculty/staff to become Champions with clear verification, profile display, directory behavior, and upgrade logic.
In Scope:
alumni (default), almost_alumni, faculty_staff/champions/verification)Out of Scope:
Membership Types:
membership_type is a single primary type with precedence:
alumnialmost_alumnifaculty_staffData Model Changes:
# cp_champions table additions
add_column :cp_champions, :membership_type, :integer, default: 0, null: false
# 0: alumni (default)
# 1: almost_alumni (current student)
# 2: faculty_staff
# Almost Alumni fields
add_column :cp_champions, :anticipated_graduation_date, :date
add_column :cp_champions, :anticipated_college_code, :string
add_column :cp_champions, :anticipated_program, :string
# Faculty/Staff fields
add_column :cp_champions, :work_email, :string
add_column :cp_champions, :profession, :string
add_column :cp_champions, :affiliated_college_code, :string
add_column :cp_champions, :affiliated_program, :string
# Optional: future-facing ID standardization
add_column :cp_champions, :bqid, :string
# alumni_id becomes optional
change_column_null :cp_champions, :alumni_id, true
# BUID uniqueness (prevent duplicate Champion identities)
add_index :cp_champions, :buid, unique: true
Where Verification Lives:
/champions/verificationCore Rule:
Domain Eligibility Hints (not auto-approval):
@bruins.belmont.edu@belmont.eduVerification Requirements:
alumni_id may be null unless they are also alumniDefault assumption: alumni match flow.
Membership resolution step: /profile/wizard/confirm_education
Flow:
Required fields at signup:
Membership change requests:
/champions/verificationTrigger: hook into degree import pipeline in Alumni Lookup.
Matching: for each alumni record updated during import, find Champions where:
cp_champions.buid == alumni.buid (preferred) OR cp_champions.alumni_id == alumni.idmembership_type == almost_alumniUpgrade: if alumni now has one or more degrees:
membership_type = alumniOptional: staff-only “Recheck education” action for a single Champion.
Deliverables:
Completion Summary (Phase 5.2): Implemented Almost Alumni and Faculty/Staff support in January 2026. Champions can now sign up as “Almost Alumni” (current students awaiting graduation) or “Faculty/Staff” with appropriate fields for each membership type. The directory and profile views display anticipated education for Almost Alumni and profession/affiliation for Faculty/Staff with appropriate badges. Also includes bio step in wizard, major autocomplete, graduation month/year picker, and admin membership type change UI.
Goal: Champions can save other Champions as personal contacts for quick access, enhanced privacy controls, and personalized content surfacing.
Use Cases:
# cp_champion_contacts table
create_table :cp_champion_contacts do |t|
t.references :champion, null: false, foreign_key: { to_table: :cp_champions }
t.references :contact, null: false, foreign_key: { to_table: :cp_champions }
t.boolean :mutual, default: false, null: false # true if both added each other
t.string :notes, limit: 255 # Optional private note (nice-to-have)
t.timestamps
end
add_index :cp_champion_contacts, [:champion_id, :contact_id], unique: true, name: "idx_champion_contacts_unique"
add_index :cp_champion_contacts, [:contact_id, :champion_id], name: "idx_contacts_reverse_lookup"
add_index :cp_champion_contacts, [:champion_id, :mutual], name: "idx_champion_contacts_mutual"
Mutual Tracking Logic:
{champion: A, contact: B, mutual: false}{champion: B, contact: A, mutual: false}, then update A’s record to mutual: true AND B’s record to mutual: truemutual: falseCurrent privacy model: Dropdown with public | district_only | hidden
New privacy model: Checkbox-based with granular control
Privacy Options Structure:
Who can see [field]?
○ Anyone
○ Limit who can see:
☐ Alumni in my district
☐ Alumni in my communities
☐ Community Leaders of my communities
☐ My contacts
Fields affected:
contact_preference)Data Model Changes:
# Add to cp_champions
add_column :cp_champions, :location_privacy, :jsonb, default: { "level": "anyone" }, null: false
# level: "anyone" | "limited"
# when limited, includes array: ["district", "communities", "community_leaders", "contacts"]
add_column :cp_champions, :messaging_privacy, :jsonb, default: { "level": "anyone" }, null: false
# level: "anyone" | "limited"
# when limited, includes array: ["district", "communities", "community_leaders", "contacts"]
Privacy Logic (one-directional):
New notification types:
contact_added — Someone added you as a contactNotification preferences:
# In notification_preferences JSONB
{
"contact_added": {
"enabled": true,
"channels": ["in_app", "email"] # Options: in_app, email, or both
}
}
Email notification: Immediate, includes:
Dashboard prompt: “Sarah added you as a contact—add her back?”
Location: Modal triggered from /messages index page
UX Pattern (Instagram-style):
┌──────────────────────────────────────┐
│ New Message ✕ │
├──────────────────────────────────────┤
│ To: [Search contacts...] │
│ ┌─────────────────────────────┐ │
│ │ Sarah Champion ✕ │ │
│ └─────────────────────────────┘ │
├──────────────────────────────────────┤
│ Suggested │
│ ┌────┐ │
│ │ 📷 │ Jane Doe ○ │
│ └────┘ Nashville, TN │
│ ┌────┐ │
│ │ 📷 │ Mike Smith ○ │
│ └────┘ Atlanta, GA │
│ ┌────┐ │
│ │ 📷 │ Sarah Champion ● │
│ └────┘ Dallas, TX │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Chat │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
Behavior:
/messages/new?recipient=:champion_id or existing threadScope: Contacts only. To message someone not in contacts, use Directory → Profile → “Say Hello”
My Contacts View: /directory/contacts
Filter Toggle: In directory filter dropdown
Add/Remove Button:
Contact Info on Profile:
Scope: Discussion boards only (events/news backlogged for future)
Formula update:
def popularity_score(viewer: nil)
base_score = (reactions_count * 2) + (comments_count * 3) + (time_factor * 10)
# Apply contact boost if viewer provided and author is in viewer's contacts
if viewer && viewer.contact_ids.include?(author_id)
base_score * 1.3
else
base_score
end
end
Implementation:
Trigger: When adding a new contact, check if any of their communities now have 5+ of your contacts
Logic:
# On contact add
def check_community_suggestions(champion, new_contact)
new_contact.communities.each do |community|
next if champion.communities.include?(community)
next if champion.community_suggestions.exists?(community: community)
contacts_in_community = champion.contacts
.joins(:communities)
.where(cp_champion_communities: { community_id: community.id })
.count
if contacts_in_community >= 5
champion.community_suggestions.create!(
community: community,
reason: "contacts_threshold",
metadata: { contact_count: contacts_in_community }
)
end
end
end
Display: Uses existing community suggestion UI on dashboard
New event types:
# Add to Cp::ActivityEvent::EVENT_TYPES
contact_added
contact_removed
contact_notes_updated # if notes implemented
Must-Haves (Priority 1):
cp_champion_contacts table with mutual tracking/directory/contacts — My Contacts viewDeferred to Backlog (Priority 2):
Backlogged:
| Location | Element |
|---|---|
| Profile | Contact button (icon-only: filled user = contact, outline = non-contact) |
| Directory Cards | Contact button in top-right corner |
| Directory | “My Contacts Only” toggle in filters |
| Directory | /directory/contacts route for contacts-only view |
| Messages | “New Message” button → Modal with contact search |
| Notifications | “X added you as a contact” in-app notification + email |
| Settings | Location privacy with “Contacts Only” option |
| Settings | Messaging privacy with “Contacts Only” option |
What Was Implemented:
cp_champion_contacts table and mutual tracking/directory/contacts route for contacts-only directory viewcontact_added and contact_removed eventsAdditional Enhancements (included with 5.3):
unread_messages_count, unread_notifications_count) for header badgesDeferred to Backlog:
Goal: Surface Champions who share commonalities with the current user, making it easier to find people they’re likely to know or want to connect with.
Use Cases:
Scoring Factors:
| Factor | Points | Notes |
|---|---|---|
| Same College | +20 | Exact college match |
| Same Major | +25 | Exact major match |
| Same Grad Year | +15 | Exact year match |
| Grad Year ±1 | +10 | Within 1 year |
| Grad Year ±2-3 | +5 | Within 2-3 years |
| Same Industry | +15 | Exact industry match |
| Shared Affinity | +10 each | Per matching affinity (max 3 counted = 30 pts) |
| Same District | +10 | Same metro area |
Maximum Score: 100+ (uncapped, but diminishing returns)
Minimum Threshold: 15 points to appear in recommendations
Example Scoring:
User A: College of Music, Music Business '18, Entertainment Industry, Nashville, [Phi Mu, SAAC]
User B: College of Music, Music Business '17, Entertainment Industry, Nashville, [Phi Mu]
Score for B relative to A:
+20 (same college)
+25 (same major)
+10 (grad year within 1)
+15 (same industry)
+10 (same district)
+10 (shared affinity: Phi Mu)
= 90 points → HIGH recommendation
Service Class: Cp::AlumniLikeMeService
class Cp::AlumniLikeMeService
WEIGHTS = {
college: 20,
major: 25,
grad_year_exact: 15,
grad_year_close: 10, # ±1 year
grad_year_near: 5, # ±2-3 years
industry: 15,
affinity: 10, # per match, max 3
district: 10
}.freeze
MINIMUM_SCORE = 15
MAX_AFFINITIES_COUNTED = 3
def initialize(champion)
@champion = champion
@profile = extract_profile(champion)
end
# Returns array of { champion:, score:, reasons: [] }
def recommendations(limit: 20, exclude_contacts: true)
candidates = base_query(exclude_contacts)
scored = candidates.map { |c| score_candidate(c) }
scored
.select { |r| r[:score] >= MINIMUM_SCORE }
.sort_by { |r| -r[:score] }
.first(limit)
end
# For specific use cases
def top_matches(limit: 5)
recommendations(limit: limit)
end
# For directory default view
def directory_suggestions(limit: 12)
recommendations(limit: limit, exclude_contacts: true)
end
private
def extract_profile(champion)
{
college_codes: champion.alumni&.degrees&.map { |d| d.major&.college_code }&.compact&.uniq || [],
major_codes: champion.alumni&.degrees&.map(&:major_code)&.compact&.uniq || [],
grad_years: champion.alumni&.degrees&.map { |d| d.degree_date&.year }&.compact&.uniq || [],
industry: champion.industry,
district_id: champion.district_id,
affinity_ids: champion.affinity_ids
}
end
def score_candidate(candidate)
score = 0
reasons = []
# College match
if (@profile[:college_codes] & candidate_colleges(candidate)).any?
score += WEIGHTS[:college]
reasons << :college
end
# Major match
if (@profile[:major_codes] & candidate_majors(candidate)).any?
score += WEIGHTS[:major]
reasons << :major
end
# Grad year proximity
year_diff = min_year_difference(candidate)
if year_diff == 0
score += WEIGHTS[:grad_year_exact]
reasons << :grad_year_exact
elsif year_diff == 1
score += WEIGHTS[:grad_year_close]
reasons << :grad_year_close
elsif year_diff.between?(2, 3)
score += WEIGHTS[:grad_year_near]
reasons << :grad_year_near
end
# Industry match
if @profile[:industry].present? && @profile[:industry] == candidate.industry
score += WEIGHTS[:industry]
reasons << :industry
end
# District match
if @profile[:district_id].present? && @profile[:district_id] == candidate.district_id
score += WEIGHTS[:district]
reasons << :district
end
# Affinity matches (capped at 3)
shared_affinities = @profile[:affinity_ids] & candidate.affinity_ids
affinity_count = [shared_affinities.count, MAX_AFFINITIES_COUNTED].min
if affinity_count > 0
score += WEIGHTS[:affinity] * affinity_count
reasons << :affinities
end
{ champion: candidate, score: score, reasons: reasons }
end
end
Reusable Design:
Problem: Scoring all Champions against current user is expensive.
Solution: Pre-compute recommendations nightly + real-time fallback.
Approach 1: Cached Recommendations Table (Preferred)
# cp_champion_recommendations table
create_table :cp_champion_recommendations do |t|
t.references :champion, null: false, foreign_key: { to_table: :cp_champions }
t.references :recommended, null: false, foreign_key: { to_table: :cp_champions }
t.integer :score, null: false
t.string :reasons, array: true, default: []
t.timestamps
end
add_index :cp_champion_recommendations, [:champion_id, :score], order: { score: :desc }
add_index :cp_champion_recommendations, [:champion_id, :recommended_id], unique: true
Nightly Job: Cp::RefreshRecommendationsJob
cp_champion_recommendationsReal-time Fallback:
1. Directory Default View
2. Dashboard Widget (Complete)
3. Profile Page (Future)
UI Pattern: Subtle badge or label explaining why recommended
| Reason | Display Text |
|---|---|
:college |
“Same college” |
:major |
“Same major” |
:grad_year_exact |
“Class of [YYYY]” |
:grad_year_close |
“Similar class year” |
:industry |
“Same industry” |
:district |
“In your area” |
:affinities |
“Shared interests” |
Display Priority: Show top 2 reasons only (highest weighted)
Must-Haves:
Cp::AlumniLikeMeService with scoring algorithm ✅Nice-to-Haves (include if time permits):
cp_champion_recommendations cache table (deferred - real-time calculation sufficient for ~500 Champions)Cp::RefreshRecommendationsJob nightly job (deferred - not needed at current scale)recommendation_viewed ✅recommendation_clicked ✅Backlogged:
Implemented January 2026:
Service: app/services/cp/alumni_like_me_service.rb
Champion#affinity_codes for efficient affinity matchingUI Integration:
Activity Tracking:
recommendation_viewed event recorded when recommendations are displayedrecommendation_clicked event type added (available for future integration)Files Created/Modified:
app/services/cp/alumni_like_me_service.rb (new)app/views/cp/directory/_recommendation_card.html.erb (new)app/views/cp/directory/index.html.erb (modified)app/controllers/cp/directory_controller.rb (modified)app/models/cp/champion.rb (added affinity_codes method)app/models/cp/activity_event.rb (added event types)test/services/cp/alumni_like_me_service_test.rb (new)Tests: 19 tests, 37 assertions, 0 failures
Design Decisions:
New event types:
# Add to Cp::ActivityEvent::EVENT_TYPES
recommendation_viewed # When recommendations displayed
recommendation_clicked # When user clicks a recommended Champion
Goal: Create a vibrant photo experience that brings a sense of community and togetherness, showing life and action across the Champion network.
Emotional Intent: Photos should make Champions feel connected to something bigger—seeing fellow Bruins gathering, celebrating, and representing Belmont across the country.
Completion Summary (January 2026):
cp_photo_albums, cp_photos, cp_photo_album_communities tables/photo-albums with album index and detail viewsphoto_album_viewed eventUse Cases:
# cp_photo_albums table
create_table :cp_photo_albums do |t|
t.string :title, null: false
t.string :slug, null: false
t.text :description
t.references :created_by, null: false, foreign_key: { to_table: :users } # Staff user
t.references :event, foreign_key: { to_table: :cp_events } # Optional: 0-1 event
t.integer :status, default: 0, null: false # 0: draft, 1: published
t.datetime :published_at
t.integer :photos_count, default: 0 # Counter cache
t.timestamps
end
add_index :cp_photo_albums, :slug, unique: true
add_index :cp_photo_albums, :status
add_index :cp_photo_albums, [:status, :published_at]
# cp_photo_album_communities (join table for multi-community association)
create_table :cp_photo_album_communities do |t|
t.references :photo_album, null: false, foreign_key: { to_table: :cp_photo_albums }
t.references :community, null: false, foreign_key: { to_table: :cp_communities }
t.timestamps
end
add_index :cp_photo_album_communities, [:photo_album_id, :community_id], unique: true, name: "idx_album_communities_unique"
# cp_photos table (separate model for photo metadata)
create_table :cp_photos do |t|
t.references :photo_album, null: false, foreign_key: { to_table: :cp_photo_albums }
t.string :caption
t.string :photographer_credit
t.date :taken_on # Defaults to event date or upload date
t.integer :position, null: false, default: 0 # For ordering
t.boolean :featured, default: false
t.datetime :featured_at
t.timestamps
end
add_index :cp_photos, [:photo_album_id, :position]
add_index :cp_photos, [:featured, :featured_at], where: "featured = true", order: { featured_at: :desc }
Key Design Decisions:
cp_photos table — Allows per-photo metadata (caption, position, featured)Album Visibility Rules:
Example Scenarios:
| Album | Event | Communities | Who Can See |
|---|---|---|---|
| “Belmont on Broadway 2026” | Belmont on Broadway 2026 event | NYC District, Musical Theater, CMPA | Members of ANY of those communities |
| “Nashville Meetups Spring 2026” | None | Nashville District | Nashville community members |
| “Homecoming 2025 Highlights” | None | None (global) | ALL Champions |
| “Music Business Alumni Gathering” | MB Alumni Mixer event | Music Business Major | Music Business community members |
Association Logic:
class Cp::PhotoAlbum < ApplicationRecord
belongs_to :created_by, class_name: "User"
belongs_to :event, class_name: "Cp::Event", optional: true
has_many :photo_album_communities, dependent: :destroy
has_many :communities, through: :photo_album_communities
has_many :photos, -> { order(position: :asc) }, dependent: :destroy
enum status: { draft: 0, published: 1 }
scope :global, -> { left_joins(:photo_album_communities).where(cp_photo_album_communities: { id: nil }) }
scope :for_community, ->(community) { joins(:photo_album_communities).where(cp_photo_album_communities: { community_id: community.id }) }
def global?
communities.empty?
end
def visible_to?(champion)
return true if global?
(communities.pluck(:id) & champion.community_ids).any?
end
end
class Cp::Photo < ApplicationRecord
belongs_to :photo_album, counter_cache: :photos_count
has_one_attached :image
validates :image, presence: true
validates :image, content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
size: { less_than: 10.megabytes }
before_create :set_default_taken_on
before_create :set_position
scope :featured, -> { where(featured: true).order(featured_at: :desc) }
scope :ordered, -> { order(position: :asc) }
def feature!
update!(featured: true, featured_at: Time.current)
end
def unfeature!
update!(featured: false, featured_at: nil)
end
private
def set_default_taken_on
self.taken_on ||= photo_album.event&.start_date || Date.current
end
def set_position
self.position = (photo_album.photos.maximum(:position) || -1) + 1
end
end
Image Variants:
# In Cp::Photo model
def thumbnail
image.variant(resize_to_fill: [200, 200])
end
def display
image.variant(resize_to_limit: [1200, 800])
end
def carousel
image.variant(resize_to_fill: [400, 300])
end
URL Structure:
# config/routes.rb (Champion Portal)
namespace :cp do
resources :photos, only: [:index] do
collection do
get :featured
end
end
resources :albums, only: [:index, :show], param: :slug do
resources :photos, only: [:show], module: :albums
end
end
Routes:
| Route | Purpose |
|——-|———|
| /photos | Index with tabs: “By Album” / “Featured” |
| /photos/featured | Featured photos only (AJAX tab) |
| /albums | All accessible albums |
| /albums/:slug | Single album grid view |
| /albums/:slug/:id | Direct link to photo (opens lightbox) |
/photos)Layout: Tabbed interface
┌─────────────────────────────────────────────────────────────────┐
│ Photos │
├─────────────────────────────────────────────────────────────────┤
│ [By Album] [Featured] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ "By Album" Tab: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ [Cover] │ │ [Cover] │ │ [Cover] │ │
│ │ │ │ │ │ │ │
│ │ Album Title │ │ Album Title │ │ Album Title │ │
│ │ 24 photos │ │ 12 photos │ │ 8 photos │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ ... │ │ ... │ │ ... │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ [Load more albums...] │
│ │
└─────────────────────────────────────────────────────────────────┘
“By Album” Tab:
“Featured” Tab:
┌─────────────────────────────────────────────────────────────────┐
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Photo │ │ Photo │ │ Photo │ │ Photo │ │ Photo │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ Caption│ │ Caption│ │ Caption│ │ Caption│ │ Caption│ │
│ │ 📍Event│ │ 📍Comm │ │ 📍Event│ │ 📍Comm │ │ 📍Event│ │
│ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ... │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ [Load more...] │
└─────────────────────────────────────────────────────────────────┘
featured_at DESC (newest first)/albums/:slug)┌─────────────────────────────────────────────────────────────────┐
│ ← Back to Photos │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Belmont on Broadway 2026 │
│ 24 photos · April 15, 2026 │
│ │
│ Description text here if provided... │
│ │
│ 📍 Event: Belmont on Broadway 2026 │
│ 🏷️ Communities: NYC District · Musical Theater · CMPA │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ │ │ ⭐ │ │ │ │ │ │ ⭐ │ │ │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ │ │ │ │ ⭐ │ │ │ │ │ │ │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
│ [Load more photos...] │
│ │
└─────────────────────────────────────────────────────────────────┘
Trigger: Click any photo thumbnail
┌─────────────────────────────────────────────────────────────────┐
│ ✕ │
│ │
│ ◀ [PHOTO] ▶ │
│ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ "Champions celebrating after the show" │
│ 📷 Photo by: Jane Smith · April 15, 2026 │
│ 📍 From: Belmont on Broadway 2026 │
│ │
│ [⚑ Report] │
└─────────────────────────────────────────────────────────────────┘
Features:
/albums/:slug/:photo_id)Stimulus Controller: lightbox_controller.js
Location: Between Discussions and News sections on dashboard
┌─────────────────────────────────────────────────────────────────┐
│ 📷 From the Community [View All →] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ◀ ┌────────────┐ ┌────────────┐ ┌────────────┐ ▶ │
│ │ │ │ │ │ │ │
│ │ Photo │ │ Photo │ │ Photo │ │
│ │ │ │ │ │ │ │
│ │ "Caption" │ │ "Caption" │ │ "Caption" │ │
│ │ 📍 Event │ │ 📍 Commun. │ │ 📍 Event │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ ● ○ ○ ○ │
│ │
└─────────────────────────────────────────────────────────────────┘
Behavior:
/photos?tab=featuredfeatured_at DESC (newest first)Stimulus Controller: carousel_controller.js
Location: On Community show page, similar placement to dashboard
┌─────────────────────────────────────────────────────────────────┐
│ Nashville Champions │
├─────────────────────────────────────────────────────────────────┤
│ [About] [Members] [Discussions] [Events] [Photos] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ (If "Photos" tab or section:) │
│ │
│ Featured Photos [View All →] │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Photo │ │ Photo │ │ Photo │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ Albums │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Album Cover │ │ Album Cover │ │
│ │ Title │ │ Title │ │
│ │ 12 photos │ │ 8 photos │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
/photos to this community’s contentLocation: On Event show page (for past events with photos)
┌─────────────────────────────────────────────────────────────────┐
│ Belmont on Broadway 2026 │
│ April 15, 2026 · NYC │
├─────────────────────────────────────────────────────────────────┤
│ │
│ (Event details...) │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 📷 Event Photos [View Album] │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ │ │ │ │ │ │ │ │ │ │+18 │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Location: Champion Admin > Photos (new section)
Sidebar: Between Events and Feedback
Routes:
# config/routes.rb (Lookup Portal, champions namespace)
namespace :champions do
resources :photo_albums do
member do
patch :publish
patch :unpublish
end
resources :photos, only: [:create, :destroy] do
member do
patch :feature
patch :unfeature
patch :move # For reordering
end
collection do
post :bulk_upload
end
end
end
end
Album Index:
┌─────────────────────────────────────────────────────────────────┐
│ Photo Albums [+ New Album] │
├─────────────────────────────────────────────────────────────────┤
│ Filter: [All ▼] [Published ▼] [Community... ▼] 🔍 Search │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────┬────────────────────────┬────────┬────────┬──────────┐ │
│ │Cover│ Title │ Photos │ Status │ Actions │ │
│ ├─────┼────────────────────────┼────────┼────────┼──────────┤ │
│ │ 📷 │ Belmont on Broadway │ 24 │ ● Pub │ Edit Del │ │
│ │ 📷 │ Nashville Spring 2026 │ 12 │ ○ Draft│ Edit Del │ │
│ │ 📷 │ Homecoming 2025 │ 45 │ ● Pub │ Edit Del │ │
│ └─────┴────────────────────────┴────────┴────────┴──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Album Form:
┌─────────────────────────────────────────────────────────────────┐
│ New Album / Edit Album │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Title * [________________________________] │
│ Slug [________________________________] (auto-gen) │
│ Description [________________________________] │
│ [________________________________] │
│ │
│ Event [Select event... ▼] (optional) │
│ │
│ Communities ☐ Nashville District │
│ ☐ Music Business │
│ ☐ College of Music │
│ ☐ CMPA │
│ (Searchable checkbox list) │
│ │
│ Status ○ Draft ● Published │
│ │
│ ──────────────────────────────────────────────────────────── │
│ │
│ Photos (drag to reorder) [+ Add Photos] │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ ≡ [Thumb] Caption: Nashville meetup... ⭐ Featured 🗑️ │ │
│ │ ≡ [Thumb] Caption: Group photo at... ☆ Feature 🗑️ │ │
│ │ ≡ [Thumb] Caption: ☆ Feature 🗑️ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Drag and drop photos here, or click to upload │ │
│ │ (Max 10MB per photo, JPEG/PNG/WebP) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Save Album] │
│ │
└─────────────────────────────────────────────────────────────────┘
Photo Edit (inline or modal):
Drag-and-Drop Upload:
image_dropzone_controller.js patternCp::Photo records with uploaded imagesReordering:
sortable_controller.js (Stimulus Sortable)position via AJAX| Action | admin | portal_admin | staff |
|---|---|---|---|
| View album list | ✅ | ✅ | ❌ |
| Create album | ✅ | ✅ | ❌ |
| Edit album | ✅ | ✅ | ❌ |
| Delete album | ✅ | ✅ | ❌ |
| Upload photos | ✅ | ✅ | ❌ |
| Feature photos | ✅ | ✅ | ❌ |
| Delete photos | ✅ | ✅ | ❌ |
Future (CL Rollout):
Active Storage Configuration:
Variants:
| Name | Dimensions | Usage |
|——|————|——-|
| thumbnail | 200×200 (fill) | Album grid, admin list |
| carousel | 400×300 (fill) | Dashboard carousel |
| display | 1200×800 (limit) | Lightbox view |
Constraints:
Validation:
# In Cp::Photo
validates :image, attached: true,
content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
size: { less_than: 10.megabytes }
validate :album_photo_limit
def album_photo_limit
if photo_album.photos.count >= 100
errors.add(:base, "Album has reached the maximum of 100 photos")
end
end
New event types:
# Add to Cp::ActivityEvent::EVENT_TYPES
album_created
album_published
photos_uploaded # metadata: { count: N }
photo_featured
photo_viewed # When lightbox opened
photo_reported
Must-Haves (Priority 1):
cp_photo_albums table with status (draft/published)cp_photo_album_communities join tablecp_photos table with position, featured, metadata/photo-albums index listing all albums (simplified from tabs spec)photo_album_viewed)Nice-to-Haves (Priority 2 — include if time):
Backlogged:
Additional Implemented:
| Location | Element | Status |
|---|---|---|
| Dashboard | “From the Community” carousel (featured photos) | ✅ Implemented |
| Nav | Photos accessible via dashboard or /photo-albums |
✅ Implemented |
/photo-albums |
Index listing all published albums | ✅ Implemented |
/photo-albums/:slug |
Album detail with photo grid + lightbox | ✅ Implemented |
| Community Page | Featured photos + albums for that community | ⏸️ Deferred |
| Event Page | Event photos section (if album exists) | ⏸️ Deferred |
| Champion Admin | Photo Albums section with full CRUD | ✅ Implemented |
| Event Admin | “Create Photo Album” button for past events | ✅ Implemented |
Models:
app/models/cp/photo_album.rbapp/models/cp/photo.rbapp/models/cp/photo_album_community.rbControllers (Champion Portal):
app/controllers/cp/photos_controller.rbapp/controllers/cp/albums_controller.rbapp/controllers/cp/albums/photos_controller.rbControllers (Admin):
app/controllers/champions/photo_albums_controller.rbapp/controllers/champions/photos_controller.rbViews (Champion Portal):
app/views/cp/photos/index.html.erbapp/views/cp/photos/_featured.html.erbapp/views/cp/albums/index.html.erbapp/views/cp/albums/show.html.erbapp/views/cp/shared/_lightbox.html.erbapp/views/cp/shared/_photo_carousel.html.erbViews (Admin):
app/views/champions/photo_albums/index.html.erbapp/views/champions/photo_albums/new.html.erbapp/views/champions/photo_albums/edit.html.erbapp/views/champions/photo_albums/_form.html.erbapp/views/champions/photos/_photo_row.html.erbJavaScript:
app/javascript/controllers/lightbox_controller.jsapp/javascript/controllers/carousel_controller.jsapp/javascript/controllers/photo_sortable_controller.jsMigrations:
db/migrate/*_create_cp_photo_albums.rbdb/migrate/*_create_cp_photo_album_communities.rbdb/migrate/*_create_cp_photos.rbTests:
test/models/cp/photo_album_test.rbtest/models/cp/photo_test.rbtest/controllers/cp/photos_controller_test.rbtest/controllers/cp/albums_controller_test.rbtest/controllers/champions/photo_albums_controller_test.rbGoal: Capture and manage user feedback during beta period with database persistence and admin interface.
Context: During beta testing, Champions can submit feedback directly from the portal. Feedback is stored in the database for tracking, with an admin interface in the Lookup Portal’s Champion Admin section.
Data Model:
# cp_feedbacks table
create_table :cp_feedbacks do |t|
t.references :cp_champion, null: false, foreign_key: true
t.string :category, null: false # bug, feature_request, general, other
t.text :message, null: false
t.string :page_url
t.string :user_agent
t.string :status, default: 'new' # new, read, in_progress, resolved, closed
t.text :internal_notes
t.datetime :read_at
t.references :read_by, foreign_key: { to_table: :users }
t.timestamps
end
Deliverables:
can_support_respond (same as CL Support)UI Patterns:
Permission Model: | Action | portal_admin | can_support_respond | staff | |——–|————–|———————|——-| | View feedback list | ✅ | ✅ | ❌ | | View feedback detail | ✅ | ✅ | ❌ | | Update status/notes | ✅ | ✅ | ❌ |
Files Created:
app/models/cp/feedback.rb — Model with validations, scopes, status enumapp/controllers/champions/feedbacks_controller.rb — Admin CRUD controllerapp/views/champions/feedbacks/ — Index, show views with TailwindUI stylingdb/migrate/*_create_cp_feedbacks.rb — Migrationtest/models/cp/feedback_test.rb — 24 model teststest/controllers/champions/feedbacks_controller_test.rb — 14 controller testsFiles Modified:
app/controllers/cp/feedback_controller.rb — Added database persistenceapp/views/shared/champions/_sidebar.html.erb — Added Feedback link with badgeconfig/routes.rb — Added feedback routesapp/mailers/champion_portal_mailer.rb — Staff notification methodapp/views/champion_portal_mailer/new_feedback_notification.html.erb — Email templatecan_support_respondmembership_type validates correctlyalumni_id works (faculty/staff case)After completing Phase 5:
roadmap_controller.rb with Phase 5 statusfeatures/| Question | Status | Notes |
|---|---|---|
| Faculty/Staff verification process? | TBD | Require BUID + @belmont.edu eligibility hint; staff approval always required |
| Almost Alumni verification? | TBD | Use @bruins.belmont.edu eligibility hint; staff approval always required |
| Almost Alumni → Alumni conversion trigger? | TBD | Hook into degree import pipeline; optional manual recheck |
| Mutual friending vs one-way favorites? | ✅ One-way | Favorites, not mutual friend requests |
| Album per event or multiple? | TBD | Single “Event Photos” album probably sufficient |
| Photo moderation needed? | TBD | Flag inappropriate photos? |
| Lookup Portal Almost Alumni workflow? | TBD | Need to formalize process for adding students to Alumni table |
| Document | Purpose |
|---|---|
| ../../JOBS-TO-BE-DONE.md | User needs context |
| ../../features/01-AUTHENTICATION.md | Auth for non-alumni |
| ../phase-1/1.5-staff-verification.md | Verification patterns (Lookup Portal: /champions/verification) |
🚀 Phase 5 completion = MVP SOFT LAUNCH READY
After this phase, the Champion Portal is ready for initial public users.