Champion Portal Development Sub-Phase 1.11
Estimated Effort: 1–2 weeks
Focus: Admin-managed events for Champion Portal (parallel to News)Prerequisites: Phase 1.10 (Community News) complete or near-complete
Related Documents:
- 1.10-community-news.md — News model this parallels
- ../../JOBS-TO-BE-DONE.md — Job C5: Stay in the Loop
- ../../BACKLOG.md — RSVP functionality deferred here
Status: COMPLETE
Database & Models:
cp_events table with all specified fields (title, slug, dates, location, RSVP, status)cp_event_districts join table for district targeting (mirrors News pattern)Cp::Event model with validations, scopes, slug generation, view/click trackingCp::EventDistrict model for district associationAdmin Interface (Lookup Portal):
Champions::EventsController with full CRUDChampion Portal:
Cp::EventsController with index, show, rsvp_click actionsTests:
Cp::Event (all passing)Cp::EventDistrict (all passing)Cp::EventsController (all passing)Key Design Decision:
| File | Purpose |
|——|———|
| db/migrate/20260107_create_cp_events.rb | Events table |
| db/migrate/20260107_create_cp_event_districts.rb | District targeting |
| app/models/cp/event.rb | Event model |
| app/models/cp/event_district.rb | Join model |
| app/controllers/champions/events_controller.rb | Admin CRUD |
| app/views/champions/events/ | Admin views (index, new, edit, _form) |
| app/controllers/cp/events_controller.rb | CP controller |
| app/views/cp/events/ | CP views (index, show, _event_card) |
| app/javascript/controllers/event_form_controller.js | Form interactions |
| app/javascript/controllers/event_scope_controller.js | Scope toggle |
| app/javascript/controllers/event_districts_controller.js | District picker |
| test/models/cp/event_test.rb | Model tests (37) |
| test/models/cp/event_district_test.rb | Join model tests (4) |
| test/controllers/cp/events_controller_test.rb | Controller tests (19) |
| test/fixtures/cp/events.yml | Event fixtures (7) |
| test/fixtures/cp/event_districts.yml | District fixtures (2) |
| File | Change |
|——|——–|
| config/routes.rb | Added admin + CP event routes |
| app/models/cp/activity_event.rb | Added event_viewed, event_rsvp_clicked types |
| app/views/layouts/champions/_header.html.erb | Added Events nav link |
Phase 1.11 introduces admin-managed events to the Champion Portal, providing a structured way to announce upcoming events to Champions. This parallels the News system from Phase 1.10 but is purpose-built for time-bound events with dates, times, and venues.
After Phase 1.11, Champions will be able to:
After Phase 1.11, Admins will be able to:
Champions need to know about events relevant to them — university-wide events, regional gatherings, community-specific meetups. Currently there’s no structured way to surface this information within the Champion Portal.
| Aspect | News Posts | Events |
|---|---|---|
| Time-bound? | No — evergreen content | Yes — has start date/time |
| Has venue? | No | Yes — physical or virtual location |
| Has RSVP? | No | Future (external link for now) |
| Past content | Remains visible | Auto-archives after event date |
| Sort order | Published date | Event date (soonest first) |
Job C5: Stay in the Loop
“When I’m busy with life but want to stay connected to Belmont, I want to see what’s happening without active effort, so I can feel part of the community even when I’m not contributing.”
Events directly address this job by surfacing “what’s happening” proactively.
Decisions made during planning interview:
| Decision | Choice | Rationale |
|---|---|---|
| Admin-only creation | Yes — no Champion submissions in 1.11 | Keep MVP simple; Champion-submitted events are Phase 2 |
| RSVP functionality | External link only (no built-in RSVP) | Reduces scope; universities already have event systems |
| Community targeting | community_id FK, null = global |
Simpler than polymorphic; “global if null” pattern |
| Past event handling | Hide from default views, show in “Past Events” tab | Keep main view fresh; preserve history |
| Event check-in integration | Out of scope — separate future phase | Different workflow, different use case |
| Slug generation | Auto-generate from title with admin override | Consistent with community slugs |
| Sub-Phase | Name | Est. Time |
|---|---|---|
| 1.11.1 | Database & Models | 1–2 days |
| 1.11.2 | Admin CRUD (Lookup Portal) | 2–3 days |
| 1.11.3 | Champion Portal Events Page | 1–2 days |
| 1.11.4 | Dashboard Widget | 1 day |
| 1.11.5 | Testing & Polish | 1 day |
cp_events Tablecreate_table :cp_events do |t|
# Content
t.string :title, null: false
t.string :slug, null: false
t.text :description # Rich text (Action Text)
t.text :short_description # Plain text for cards (150 chars)
# Event details
t.datetime :starts_at, null: false
t.datetime :ends_at # Optional end time
t.string :timezone, default: 'America/Chicago'
t.string :venue_name # "Belmont University" or "Virtual"
t.string :venue_address # Full address or empty for virtual
t.boolean :is_virtual, default: false
t.string :virtual_url # Zoom/Teams link for virtual events
# External RSVP (no built-in RSVP in 1.11)
t.string :rsvp_url # Link to external RSVP system
t.string :rsvp_button_text, default: 'RSVP' # "Register", "Get Tickets", etc.
# Targeting
t.references :community, foreign_key: { to_table: :cp_communities }, null: true
# null = global (all Champions), set = specific community only
# Publishing
t.integer :status, default: 0 # draft, published, archived
t.datetime :published_at
# Metadata
t.references :created_by, foreign_key: { to_table: :users }
t.timestamps
t.index :slug, unique: true
t.index :starts_at
t.index [:status, :starts_at]
t.index :community_id
end
Cp::Event# app/models/cp/event.rb
module Cp
class Event < ApplicationRecord
self.table_name = 'cp_events'
# Associations
belongs_to :community, class_name: 'Cp::Community', optional: true
belongs_to :created_by, class_name: 'User'
has_rich_text :description
has_one_attached :cover_image
# Enums
enum :status, { draft: 0, published: 1, archived: 2 }
# Validations
validates :title, presence: true, length: { maximum: 200 }
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
validates :starts_at, presence: true
validates :short_description, length: { maximum: 150 }
validates :rsvp_url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true }
validate :ends_at_after_starts_at, if: -> { ends_at.present? }
# Scopes
scope :published, -> { where(status: :published) }
scope :upcoming, -> { where('starts_at >= ?', Time.current) }
scope :past, -> { where('starts_at < ?', Time.current) }
scope :global, -> { where(community_id: nil) }
scope :for_community, ->(community) { where(community: community).or(global) }
scope :visible_to, ->(champion) {
community_ids = champion.community_ids
where(community_id: [nil] + community_ids)
}
scope :chronological, -> { order(starts_at: :asc) }
scope :reverse_chronological, -> { order(starts_at: :desc) }
# Callbacks
before_validation :generate_slug, on: :create, if: -> { slug.blank? }
# Instance methods
def global?
community_id.nil?
end
def upcoming?
starts_at >= Time.current
end
def past?
starts_at < Time.current
end
def formatted_date
if ends_at.present? && ends_at.to_date != starts_at.to_date
"#{starts_at.strftime('%B %d')} – #{ends_at.strftime('%B %d, %Y')}"
else
starts_at.strftime('%B %d, %Y')
end
end
def formatted_time
if ends_at.present?
"#{starts_at.strftime('%l:%M %p')} – #{ends_at.strftime('%l:%M %p %Z')}"
else
starts_at.strftime('%l:%M %p %Z')
end
end
private
def generate_slug
base_slug = title.to_s.parameterize
self.slug = base_slug
# Ensure uniqueness
counter = 1
while Cp::Event.exists?(slug: slug)
self.slug = "#{base_slug}-#{counter}"
counter += 1
end
end
def ends_at_after_starts_at
errors.add(:ends_at, 'must be after start time') if ends_at <= starts_at
end
end
end
┌─────────────────────────────────────────────────────────────┐
│ 📅 Upcoming Events View All → │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ JAN Nashville Alumni Mixer │ │
│ │ 15 Belmont University • 6:00 PM │ │
│ │ [RSVP →] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ JAN Virtual Career Workshop │ │
│ │ 22 Online • 12:00 PM │ │
│ │ [Register →] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ FEB Atlanta Chapter Meetup │ │
│ │ 3 Piedmont Park • 2:00 PM │ │
│ │ [Learn More →] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Events │
│ Discover what's happening in your Belmont community │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [Upcoming ▼] [All Communities ▼] 🔍 Search events │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ┌─────────┐ │ │
│ │ │ COVER │ Nashville Alumni Mixer │ │
│ │ │ IMAGE │ 📅 January 15, 2026 • 6:00 PM │ │
│ │ │ │ 📍 Belmont University, Nashville │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Join fellow Bruins for an evening of networking and fun at the │ │
│ │ annual Nashville Alumni Mixer. Heavy appetizers provided. │ │
│ │ │ │
│ │ 🏷️ Nashville District [RSVP →] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ┌─────────┐ │ │
│ │ │ COVER │ Virtual Career Workshop: Tech Industry │ │
│ │ │ IMAGE │ 📅 January 22, 2026 • 12:00 PM │ │
│ │ │ │ 💻 Virtual Event │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Learn from Belmont alumni working in tech. Panel discussion │ │
│ │ followed by Q&A and networking breakout rooms. │ │
│ │ │ │
│ │ 🌐 All Champions [Register →] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ [Past Events] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Champion Portal › Events [+ New Event]│
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [All ▼] [All Communities ▼] 🔍 Search... │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Title │ Date │ Target │ Status │ Actions │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ Nashville Mixer │ Jan 15 │ Nashville │ ✅ Pub │ Edit │ Del │ │
│ │ Career Workshop │ Jan 22 │ Global │ ✅ Pub │ Edit │ Del │ │
│ │ Atlanta Meetup │ Feb 3 │ Atlanta │ 📝 Draft│ Edit │ Del │ │
│ │ Spring Reunion │ Mar 20 │ Global │ 📝 Draft│ Edit │ Del │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Showing 4 events │
│ │
└─────────────────────────────────────────────────────────────────────────┘
| Feature | Location | Why |
|---|---|---|
| Event CRUD | Lookup Portal (/settings/champion_events) |
Admins manage from central admin area |
| Events display | Champion Portal (/events, Dashboard widget) |
Champions view in their portal |
| Action | Required Role |
|---|---|
| View events (Champion Portal) | Any verified Champion |
| Create/Edit/Delete events | portal_admin or admin |
# Lookup Portal (config/routes.rb - main domain)
namespace :settings do
resources :champion_events, except: [:show]
end
# Champion Portal (config/routes.rb - champions subdomain)
constraints(SubdomainConstraint.new('champions')) do
scope module: 'cp' do
resources :events, only: [:index, :show]
end
end
| Feature | Description |
|---|---|
cp_events table |
Full schema as defined above |
Cp::Event model |
Validations, scopes, methods |
| Admin CRUD | Create, edit, delete events in Lookup Portal |
| Events index | /events page in Champion Portal |
| Event show | /events/:slug detail page |
| Dashboard widget | “Upcoming Events” showing next 3 events |
| Community targeting | Events targeted to specific communities or global |
| Cover images | Optional cover image upload |
| External RSVP | Link to external registration system |
| Feature | Reason | Target Phase |
|---|---|---|
| Built-in RSVP | Complexity; external systems exist | Backlog |
| Champion-submitted events | Requires approval workflow | Phase 2 |
| Event check-in integration | Different use case, separate workflow | Future |
| Recurring events | Complexity | Backlog |
| Calendar export (ICS) | Nice-to-have | Backlog |
| Event reminders | Requires notification system | Phase 2+ |
| Metric | Target |
|---|---|
| Events created in first month | 5+ |
| Champion event page views | 50+ per week |
| RSVP link click-through | 20%+ of event views |
# test/models/cp/event_test.rb
class Cp::EventTest < ActiveSupport::TestCase
test "validates title presence"
test "validates starts_at presence"
test "validates slug uniqueness"
test "validates ends_at after starts_at"
test "validates rsvp_url format"
test "generates slug from title"
test "scope upcoming returns future events"
test "scope past returns past events"
test "scope visible_to includes global and community events"
test "global? returns true when community_id nil"
end
# test/controllers/settings/champion_events_controller_test.rb
class Settings::ChampionEventsControllerTest < ActionDispatch::IntegrationTest
test "index requires portal_admin"
test "index lists all events"
test "create creates event with valid params"
test "create fails with invalid params"
test "update updates event"
test "destroy deletes event"
end
# test/controllers/cp/events_controller_test.rb
class Cp::EventsControllerTest < ActionDispatch::IntegrationTest
test "index requires verified champion"
test "index shows upcoming events"
test "index filters by community"
test "show displays event details"
test "show renders 404 for draft events"
end
After completing Phase 1.11:
app/controllers/champions/roadmap_controller.rb statusdocs/features/champion_portal/EVENTS_SYSTEM.md| Question | Answer | Date |
|---|---|---|
| Should events have built-in RSVP? | No — external link only for MVP | Jan 7, 2026 |
| How to handle past events? | Hide from default, show in “Past Events” tab | Jan 7, 2026 |
| Event check-in integration? | Separate future phase | Jan 7, 2026 |
| Community targeting pattern? | community_id FK, null = global |
Jan 7, 2026 |
| Slug generation? | Auto-generate with admin override | Jan 7, 2026 |
db/migrate/XXXXXX_create_cp_events.rbapp/models/cp/event.rbapp/controllers/settings/champion_events_controller.rbapp/views/settings/champion_events/index.html.erbapp/views/settings/champion_events/new.html.erbapp/views/settings/champion_events/edit.html.erbapp/views/settings/champion_events/_form.html.erbapp/controllers/cp/events_controller.rbapp/views/cp/events/index.html.erbapp/views/cp/events/show.html.erbapp/views/cp/dashboard/_events_widget.html.erbtest/models/cp/event_test.rbtest/controllers/settings/champion_events_controller_test.rbtest/controllers/cp/events_controller_test.rbtest/fixtures/cp/events.ymlconfig/routes.rb — Add event routesapp/views/cp/dashboard/show.html.erb — Add events widgetapp/views/shared/_cp_nav.html.erb — Add Events nav link