Status: ✅ COMPLETE (All Sub-Phases 3.1-3.11 Delivered)
Estimated Effort: 4-6 weeks
Prerequisites: Phase 2 Complete (Community Leadership)Last Updated: January 17, 2026 (Phase 3 Wrap Complete)
Completed: January 2026
Test Results: 2267 tests, 0 failures, 0 errors
| Component | Details |
|---|---|
| Dashboard | Google News layouts for Discussions (1+3) and News (1+3) sections |
| Events Section | Date badge rows with community pills using _event_row partial |
| Communities Card | Combined with CL indicator (⭐), Leadership Dashboard button for CLs |
| Community Show | Restructured to match dashboard pattern, width aligned to max-w-7xl |
| Community Index | Complete redesign as “Community Select” with My Communities, You’re Invited, Explore All |
| Layout Fixes | Background opacity increased, footer visibility fixed, consistent max-w-7xl widths |
app/views/cp/dashboard/show.html.erb — Major Google News-style restructureapp/views/cp/communities/show.html.erb — Restructured to match dashboard, width alignedapp/views/cp/communities/index.html.erb — Complete redesignapp/controllers/cp/communities_controller.rb — Added load_invited_community_contextapp/views/cp/shared/_discussion_card_compact.html.erb — Added small_thumbnail optionapp/views/cp/shared/_news_card_compact.html.erb — Added small_thumbnail optionapp/views/cp/shared/_event_row.html.erb — Added show_community optionapp/views/layouts/champions.html.erb — Background opacity and gradient fixestest/controllers/cp/communities_controller_test.rb — Updated for new index structureCompleted: January 2026
Test Results: 2267 tests, 0 failures, 0 errors
| Component | Details |
|---|---|
| News Index | Google News-style layout (1 featured + 3 compact cards) |
| Events Index | Upcoming events with Google News layout, Past Events page |
| Discussions Index | Cross-community page at /discussions route |
| Partials | _news_card_compact, _event_card_compact, _discussion_card_compact |
| Event Row | _event_row partial with date badge styling |
app/controllers/cp/discussions_controller.rb — Cross-community discussions indexapp/views/cp/discussions/index.html.erb — All discussions pageapp/views/cp/events/index_past.html.erb — Past events with Load Moreapp/views/cp/shared/_discussion_card_compact.html.erbapp/views/cp/shared/_event_card_compact.html.erbapp/views/cp/shared/_news_card_compact.html.erbapp/views/cp/shared/_event_row.html.erbtest/controllers/cp/discussions_controller_test.rbapp/views/cp/news/index.html.erb — Google News-style layoutapp/views/cp/events/index.html.erb — Redesigned with featured + compact cardsconfig/routes.rb — Added GET /discussions routeCompleted: January 15, 2026
Test Results: 2140 tests, 0 failures, 0 errors (112 new tests added)
| Component | Details |
|---|---|
| Migrations | 8 total: cp_board_posts, cp_board_comments, cp_board_reactions, cp_post_flags, cp_user_blocks, cp_hidden_contents, cp_moderation_actions, allow_null_moderator |
| Models | 7 new: BoardPost, BoardComment, BoardReaction, PostFlag, UserBlock, HiddenContent, ModerationAction |
| ActionText | Rich text for posts (40K limit) and comments (2K limit) |
| Controller | Cp::BoardsController with index/show actions (read-only for 3.1) |
| Views | boards/index, boards/show, _comment partial, discussions section on community show |
| Activity Events | board_view, post_view for analytics |
| National Community | “Alumni Champions” created (ID: 47, national: true) |
| Fixtures | 8 fixture files with sample data |
| Tests | 96 model tests + 16 controller tests = 112 new |
national boolean to cp_communities for auto-join behaviorcp_hidden_contents table (not JSONB in champion)active (not visible): { active: 0, hidden: 1, deleted: 2 }prefix: :action for enum to avoid Rails conflictsMigrations:
db/migrate/2025*_add_discussion_board_fields_to_cp_communities.rbdb/migrate/2025*_create_cp_board_posts.rbdb/migrate/2025*_create_cp_board_comments.rbdb/migrate/2025*_create_cp_board_reactions.rbdb/migrate/2025*_create_cp_post_flags.rbdb/migrate/2025*_create_cp_user_blocks.rbdb/migrate/2025*_create_cp_hidden_contents.rbdb/migrate/2025*_create_cp_moderation_actions.rbdb/migrate/2025*_allow_null_moderator_on_moderation_actions.rbModels:
app/models/cp/board_post.rbapp/models/cp/board_comment.rbapp/models/cp/board_reaction.rbapp/models/cp/post_flag.rbapp/models/cp/user_block.rbapp/models/cp/hidden_content.rbapp/models/cp/moderation_action.rbController & Views:
app/controllers/cp/boards_controller.rbapp/views/cp/boards/index.html.erbapp/views/cp/boards/show.html.erbapp/views/cp/boards/_comment.html.erbTests:
test/models/cp/board_post_test.rb (17 tests)test/models/cp/board_comment_test.rb (17 tests)test/models/cp/board_reaction_test.rb (13 tests)test/models/cp/post_flag_test.rb (16 tests)test/models/cp/user_block_test.rb (12 tests)test/models/cp/hidden_content_test.rb (12 tests)test/models/cp/moderation_action_test.rb (9 tests)test/controllers/cp/boards_controller_test.rb (16 tests)Fixtures:
test/fixtures/cp/board_posts.ymltest/fixtures/cp/board_comments.ymltest/fixtures/cp/board_reactions.ymltest/fixtures/cp/post_flags.ymltest/fixtures/cp/user_blocks.ymltest/fixtures/cp/hidden_contents.ymltest/fixtures/cp/moderation_actions.ymlSeeds:
db/seeds/development/discussion_boards.rblib/tasks/national_community.rakeRelated Documents:
- ../README.md — Phase Index
- ../../features/05-DISCUSSION-BOARDS.md — Feature spec
- ../../JOBS-TO-BE-DONE.md — Jobs C5, L3, L4
Phase 3 introduces Discussion Boards — enabling Champions to have threaded conversations within their communities and across the national Champion network. The design prioritizes belonging and safety over engagement metrics, with robust moderation tools and clear community guidelines.
| Feature | Description |
|---|---|
| Per-Community Boards | Every community auto-gets a discussion board (with on/off toggle) |
| National Board | Single “Alumni Champions” board for cross-community discussions |
| Collapsible Threaded Comments | Reddit-style nesting with collapse at depth 3+ |
| Emoji Reactions | Reuses existing MessageReaction pattern |
| Rich Text Formatting | Bold, italic, bullets, numbered lists, links |
| User Safety Controls | Report, hide content, block users |
| Moderation Tools | Community Leaders can moderate their community’s board |
| Notification Integration | Champions notified of replies |
Every community automatically has a discussion board — no separate “board creation” step. When a community is created, its board exists. Admins can disable boards per-community via a toggle. This simplifies the model and ensures consistent coverage.
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.”
Job L3: Communicate with My Community
“When I have news or announcements for my community’s Champions, I want to reach them all at once, so I can keep everyone informed without individual emails.”
Champions can find each other (Directory) and message privately (Messaging), but there’s no public forum for community-wide discussions, announcements, or Q&A. Discussion boards fill this gap.
This is NOT Facebook, Reddit, or Quora. Our boards prioritize:
We explicitly avoid:
These decisions were finalized in the January 2026 planning interview:
| Decision | Choice | Rationale |
|---|---|---|
| Reactions | Emoji reactions (mirror Messages) | Reuse existing code, warmer than upvotes |
| Threading | Collapsible deep nesting (Reddit-style) | Enables rich conversation threads |
| Thread Display | Show 2 levels, collapse at 3+ | Clean UI with “View X more replies” |
| Post Sorting | Latest activity (last_activity_at) |
Active threads rise to top |
| Comment Sorting | Latest activity in thread | Fresh conversation first |
| Rich Text | Bold, italic, bullets, numbers, links | Posts AND comments |
| Post Limit | 40,000 characters | With truncation + “See more” |
| Comment Limit | 2,000 characters | Keeps discussions clean |
| Images | Single image per post | MVP simplicity |
| Edit Window | 30 minutes | With “Edited” indicator |
| User Controls | Report, Hide content, Block user | Safety-first approach |
| CL Controls | Hide, Lock, Pin, Escalate | Current spec is sufficient |
| Anonymous | Not allowed | Everyone shows their name |
| Board Toggle | Admin can disable per community | Flexibility for edge cases |
| Time Decay | 14 days (hardcoded constant) | For popularity calculations |
| National Board | Only global channel | Single place for cross-community content |
| National Membership | Auto-join, cannot leave, can mute | Ensures announcements reach everyone |
| National Visibility | Verified Champions only | Maintains community trust |
Every Cp::Community has an associated discussion board.
| Community Type | Example Board |
|---|---|
| District | “Nashville District” board |
| College | “College of Music” board |
| Major | “Music Business Major” board |
| Affinity | “Greek Life” board |
| Industry | “Healthcare” board |
| Custom | “Young Alumni” board |
Access: Members of the community can view and post. Non-members can view but not post (configurable per community).
A single national board for all verified Champions.
Purpose:
Access: All verified Champions can view and post.
Based on scope decisions, the following are NOT separate boards:
Goal: Create the database structure, base UI, and board on/off toggle.
Deliverables:
cp_board_posts table (with last_activity_at for sorting)cp_board_comments table (with parent_id for unlimited nesting)cp_board_reactions table (polymorphic, mirrors MessageReaction)cp_post_flags table (polymorphic)cp_user_blocks tablecp_hidden_contents table (separate table, not JSONB)cp_moderation_actions table (audit trail)discussion_board_enabled and national booleans to cp_communitiesCp::BoardPost, Cp::BoardComment, Cp::BoardReaction, Cp::PostFlag, Cp::UserBlock, Cp::HiddenContent, Cp::ModerationAction modelslast_activity_at descKey Decisions:
parent_id self-referential associationCompleted: January 15, 2026
Test Results: 2161 tests, 0 failures, 0 errors (20 new controller tests added)
| Component | Details |
|---|---|
| Post Creation | New post form with title (100 char limit), rich text body (40K limit), optional single image upload |
| Comment System | Full CRUD for comments with threaded replies (MAX_DEPTH=2), parent-child relationships |
| Edit/Delete | 30-minute edit window with “Edited” indicator, delete anytime for authors |
| Character Limits | Title: 100 chars, Post body: 40K chars, Comment body: 2K chars |
| Truncation | Post body truncation with “See more” link on listing pages |
| Activity Tracking | Events: post_created, post_edited, post_deleted, comment_created, comment_edited, comment_deleted |
| Community Guidelines | Guidelines reminder in post composer with link to full FAQ |
| FAQ Updates | 9 new FAQ entries for Discussions category in config/faq.yml |
| Communities Index | Discussion section added to featured community cards with recent posts |
| Controller Tests | 20 new tests for BoardCommentsController (create, edit, update, destroy) |
Controller Tests:
test/controllers/cp/board_comments_controller_test.rb (20 tests)Models:
app/models/cp/board_post.rb — Changed title max length from 255 to 100Views:
app/views/cp/boards/new.html.erb — Updated maxlength to 100app/views/cp/boards/edit.html.erb — Updated maxlength to 100app/views/cp/communities/index.html.erb — Added discussions to featured community cardsTests:
test/models/cp/board_post_test.rb — Updated title length test for 100 char limitConfiguration:
config/faq.yml — Added 9 new FAQ entries for Discussions categoryGoal: Champions can create posts and reply with threaded comments.
Deliverables:
Permissions: | Action | Who Can Do It | |——–|—————| | Create post | Community members | | Create comment | Community members | | Edit own post | Author (within 30 minutes) | | Delete own post | Author (anytime) | | Edit own comment | Author (within 30 minutes) | | Delete own comment | Author (anytime) |
Status: ✅ Complete (January 2026)
Test Results: 2173 tests, 0 failures, 0 errors
Goal: Enable engagement through reactions and smart sorting.
Deliverables:
MessageReaction pattern)reactions_count counter cache on posts and commentslast_activity_at updated on new comments (for “fresh thread” sorting)What Was Implemented:
| Component | Details |
|---|---|
| Routes | POST /discussions/:id/toggle_reaction, POST /discussions/:id/comments/:id/toggle_reaction |
| Models | popularity_score method, by_popularity scope, reaction helpers on BoardPost/BoardComment |
| Views | _reactions.html.erb partial (horizontal emoji bar), _post_card.html.erb partial with hot badge |
| Stimulus | board_reactions_controller.js for AJAX toggle |
| Sorting | Dashboard (top 5), Community index (top 2), Community show (top 3), Discussion index (hot 2 + chronological) |
| Activity | reaction_added event type for analytics |
Popularity Formula:
TIME_DECAY_DAYS = 14
def popularity_score
age_in_days = (Time.current - created_at) / 1.day
time_factor = [1.0 - (age_in_days / TIME_DECAY_DAYS), 0].max
(reactions_count * 2) + (comments_count * 3) + (time_factor * 10)
end
Files Created:
app/views/cp/boards/_reactions.html.erbapp/views/cp/boards/_post_card.html.erbapp/javascript/controllers/board_reactions_controller.jsTechnical Note: by_popularity scope returns an Array (uses Ruby sort_by). Pattern: .includes().by_popularity.first(n) — never chain .limit() or .includes() after by_popularity.
Status: ✅ Complete (January 2026)
Goal: Champions can protect themselves from unwanted content.
What Was Implemented:
| Feature | Details |
|---|---|
| Flag content | Button on posts and comments with reason selector |
| Flag reasons | spam, inappropriate, harassment, off-topic, other + optional notes |
| PostFlag model | cp_post_flags table with status enum (pending, resolved, dismissed) |
| Hide content | Per-champion hiding via cp_hidden_contents table |
| Block users | cp_user_blocks table, hides all their content and messaging |
| Notification filter | Blocked user messages excluded from notification count/dropdown |
Key Files:
app/models/cp/post_flag.rb — Flag model with status enumapp/models/cp/hidden_content.rb — User-specific content hidingapp/models/cp/user_block.rb — User blocking with cascade effectsapp/controllers/cp/flags_controller.rb — Flag creation endpointapp/controllers/cp/blocks_controller.rb — Block/unblock endpointsapp/controllers/cp/hidden_contents_controller.rb — Hide/unhide endpointsapp/views/cp/boards/_post_actions.html.erb — Action dropdown with flag/hide/blockapp/models/cp/champion.rb — unread_notifications? excludes blocked usersTests: Model and controller tests for all safety features
Status: ✅ Complete (January 2026)
Goal: Community Leaders can moderate their community’s board effectively.
What Was Implemented:
| Component | Details |
|---|---|
| CLC Moderation Actions | Hide/unhide content, lock/unlock posts, pin/unpin posts |
| Escalation | CLCs can escalate flagged content to Engagement Team |
| ModerationAction model | Full audit trail with actor, action, target, notes |
| CLC Moderation Queue | Shows only PENDING flags (resolved/dismissed excluded) |
| Staff Discussions Admin | /champions/discussions with tabs for all/escalated/hidden/deleted |
| Action Items Integration | Flagged + Escalated counts in staff navbar dropdown |
| Count Synchronization | Leadership index, community dashboard, queue all consistent |
Moderation Actions Enum:
| Action | Code | Effect |
|——–|——|——–|
| action_hide | 0 | Hide from all non-moderators |
| action_unhide | 1 | Make visible again |
| action_lock | 2 | Prevent new comments |
| action_unlock | 3 | Allow comments again |
| action_pin | 4 | Pin to top of board |
| action_unpin | 5 | Remove pin |
| action_escalate | 6 | Flag for Engagement Team |
| action_delete | 7 | Soft delete (staff only) |
| action_resolve_escalation | 8 | Mark escalation resolved |
Key Files:
app/controllers/cp/moderation_controller.rb — CLC moderation actionsapp/controllers/champions/discussions_controller.rb — Staff admin interfaceapp/views/cp/moderation/queue.html.erb — CLC moderation queueapp/views/champions/discussions/ — Staff admin views (index, tabs)app/services/cp/action_items_service.rb — Admin dropdown countsapp/controllers/cp/leadership_controller.rb — Community stats with pending countsdocs/features/champion_portal/DISCUSSION_MODERATION.md — Comprehensive documentationTests: 2243 tests passing (0 failures, 0 errors)
Status: ✅ Complete (January 2026)
Test Results: 2310 tests, 0 failures, 0 errors
| Component | Details |
|---|---|
| National Community | “Alumni Champions” (ID: 47) with national: true flag |
| Auto-Join Membership | All verified Champions auto-join on verification, cannot leave |
| Mute Notifications | Per-community notification muting in Settings → Notifications |
| Community List | Hidden from non-verified Champions, visible to verified |
| Board Navigation | Main nav link “Alumni Champions”, communities nav integration |
| Dashboard Integration | Popular national board posts surfaced on dashboard |
| Content Defaults | News/events without community assignment default to National Board |
| Staff Pinning | Community Leaders can pin posts to national board |
Key Files:
app/models/cp/community.rb — Added national boolean column + national? methodapp/models/cp/champion.rb — Auto-join logic on verificationapp/views/cp/communities/index.html.erb — Hide national from non-verifiedapp/controllers/cp/communities_controller.rb — National community filteringadd_national_to_cp_communities.rbGoal: Champions stay informed about board activity.
Notification Triggers: | Trigger | Who Gets Notified | |———|——————-| | Reply to your post | Post author | | Reply to your comment | Comment author | | Reaction on your post/comment | Post/comment author | | Post in community you lead | Community Leaders | | Pinned announcement | All community members (optional email) |
Notification Channels:
Deliverables:
| Status:** ✅ Complete (January 2026)
Test Results: 2350 tests, 0 failures, 0 errors
| Component | Details |
|---|---|
| Admin Dashboard | /insights/discussion_boards with metrics and analytics |
| Key Metrics | Total posts, comments, active discussers, pending flags |
| Trends | Posts/comments per week with sparkline charts |
| Community Breakdown | Top 5 communities by activity (posts + comments) |
| Moderation Stats | Flags by status (pending/resolved/dismissed), action counts |
| Period Selector | This Week, This Month, This Quarter, All Time |
| Service Layer | EngagementStats::DiscussionBoardsService for metrics calculation |
| Links to Action | Pending flags count links to moderation queue |
Key Files:
app/controllers/champions/insights_controller.rb — Discussion boards tabapp/views/champions/insights/discussion_boards.html.erb — Dashboard viewapp/services/engagement_stats/discussion_boards_service.rb — Metrics servicetest/services/engagement_stats/discussion_boards_service_test.rb — Service teststest/controllers/champions/insights_controller_test.rb — Controller testsMetrics Included:
Tests: 45+ tests for service and controller
Note: Advanced analytics (content trends, user engagement patterns, exportable reports) deferred to backlog.
Goal: Create public preview pages for events, discussions, and communities to support rich link sharing and convert visitors to signups.
Spec Document: 3.9-public-landing-pages.md
Summary:
Deliverables:
_public_cta_box.html.erb reusable componentGoal: Create dedicated index pages for News, Events, and Discussions with Google News-inspired layouts.
Summary:
/news index with featured section (1 large + 3 compact) and chronological listing/events index with featured section and chronological listing/discussions index with hot discussions + all discussions_news_card_compact, _event_card_compact, _discussion_card_compactfeatured: true/false and show_community: true/false optionsDeliverables:
Cp::NewsController#index with featured + chronological sectionsCp::EventsController#index with featured + chronological sectionsCp::DiscussionsController#index with hot + all sections_news_card_compact.html.erb partial_event_card_compact.html.erb partial_discussion_card_compact.html.erb partialGoal: Restructure dashboard with Google News-style content sections and improved layout.
Spec Document: 3.11-dashboard-redesign.md
Summary:
Deliverables:
DashboardController with featured content loading_event_row.html.erb partial (date badge style)cp_board_posts| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
community_id |
bigint FK | Required |
author_id |
bigint FK | Cp::Champion |
title |
string | Required, max 255 chars |
body |
text | ActionText rich text, max 40K chars |
status |
integer | 0: active, 1: hidden, 2: deleted |
pinned |
boolean | Default false |
locked |
boolean | Default false |
comments_count |
integer | Counter cache |
reactions_count |
integer | Counter cache |
last_activity_at |
datetime | Updated on new comments (for sorting) |
edited_at |
datetime | Null if never edited |
created_at |
datetime | — |
updated_at |
datetime | — |
Indexes:
(community_id, last_activity_at) — Board listing sort(community_id, pinned, last_activity_at) — Pinned-first sort(author_id) — User’s postscp_board_comments| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
post_id |
bigint FK | Required |
author_id |
bigint FK | Cp::Champion |
parent_id |
bigint FK | Self-reference for threading (nullable = top-level) |
body |
text | ActionText rich text, max 2K chars |
status |
integer | 0: active, 1: hidden, 2: deleted |
reactions_count |
integer | Counter cache |
replies_count |
integer | Counter cache for direct replies |
last_reply_at |
datetime | For thread sorting (nullable) |
depth |
integer | Nesting level (0 = top-level, computed on save) |
edited_at |
datetime | Null if never edited |
created_at |
datetime | — |
updated_at |
datetime | — |
Indexes:
(post_id, parent_id, last_reply_at) — Thread display(author_id) — User’s comments(parent_id) — Finding repliescp_board_reactions| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
reactable_type |
string | “Cp::BoardPost” or “Cp::BoardComment” |
reactable_id |
bigint | — |
champion_id |
bigint FK | Who reacted |
emoji |
string | Emoji character (e.g., “👍”, “❤️”, “😄”) |
created_at |
datetime | — |
Indexes:
(reactable_type, reactable_id, champion_id) UNIQUE — One reaction type per user per content(champion_id) — User’s reactionscp_post_flags| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
flaggable_type |
string | “Cp::BoardPost” or “Cp::BoardComment” |
flaggable_id |
bigint | — |
reporter_id |
bigint FK | Cp::Champion who flagged |
reason |
integer | 0: spam, 1: inappropriate, 2: off_topic, 3: harassment, 4: other |
notes |
text | Optional explanation |
status |
integer | 0: pending, 1: resolved, 2: dismissed |
resolved_by_id |
bigint FK | CL/Staff who resolved (nullable) |
resolved_at |
datetime | — |
created_at |
datetime | — |
Indexes:
(flaggable_type, flaggable_id) — All flags for content(status) — Pending flags queue(reporter_id) — User’s reportscp_user_blocks| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
blocker_id |
bigint FK | Champion who blocked |
blocked_id |
bigint FK | Champion who is blocked |
created_at |
datetime | — |
Indexes:
(blocker_id, blocked_id) UNIQUE — One block per pair(blocked_id) — Find who blocked this usercp_hidden_content| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
champion_id |
bigint FK | Who hid the content |
hideable_type |
string | “Cp::BoardPost” or “Cp::BoardComment” |
hideable_id |
bigint | — |
created_at |
datetime | — |
Indexes:
(champion_id, hideable_type, hideable_id) UNIQUEcp_moderation_actions| Column | Type | Notes |
|---|---|---|
id |
bigint | — |
moderator_id |
bigint FK | CL/Staff who acted |
target_type |
string | “Cp::BoardPost” or “Cp::BoardComment” |
target_id |
bigint | — |
action |
integer | 0: hide, 1: unhide, 2: lock, 3: unlock, 4: pin, 5: unpin, 6: escalate |
notes |
text | Optional moderator notes |
created_at |
datetime | — |
# Add to cp_communities
add_column :cp_communities, :discussion_board_enabled, :boolean, default: true
# app/models/cp/community.rb (additions)
class Cp::Community < ApplicationRecord
has_many :board_posts, class_name: "Cp::BoardPost", dependent: :destroy
def discussion_board_enabled?
discussion_board_enabled
end
end
# app/models/cp/board_post.rb
class Cp::BoardPost < ApplicationRecord
belongs_to :community, class_name: "Cp::Community"
belongs_to :author, class_name: "Cp::Champion"
has_many :comments, class_name: "Cp::BoardComment", dependent: :destroy
has_many :reactions, as: :reactable, class_name: "Cp::BoardReaction", dependent: :destroy
has_many :flags, as: :flaggable, class_name: "Cp::PostFlag", dependent: :destroy
has_rich_text :body
has_one_attached :image
enum status: { active: 0, hidden: 1, deleted: 2 }
validates :title, presence: true, length: { maximum: 255 }
validates :body, length: { maximum: 40_000 }
scope :visible, -> { where(status: :active) }
scope :by_activity, -> { order(pinned: :desc, last_activity_at: :desc) }
# 30-minute edit window
def editable?
created_at > 30.minutes.ago
end
def edited?
edited_at.present?
end
# Popularity score for surfacing (14-day decay)
TIME_DECAY_DAYS = 14
def popularity_score
age_in_days = (Time.current - created_at) / 1.day
time_factor = [1.0 - (age_in_days / TIME_DECAY_DAYS), 0].max
(reactions_count.to_i * 2) + (comments_count.to_i * 3) + (time_factor * 10)
end
# Touch last_activity_at when comments added
def touch_activity!
update_column(:last_activity_at, Time.current)
end
end
# app/models/cp/board_comment.rb
class Cp::BoardComment < ApplicationRecord
belongs_to :post, class_name: "Cp::BoardPost", counter_cache: :comments_count
belongs_to :author, class_name: "Cp::Champion"
belongs_to :parent, class_name: "Cp::BoardComment", optional: true, counter_cache: :replies_count
has_many :replies, class_name: "Cp::BoardComment", foreign_key: :parent_id, dependent: :destroy
has_many :reactions, as: :reactable, class_name: "Cp::BoardReaction", dependent: :destroy
has_many :flags, as: :flaggable, class_name: "Cp::PostFlag", dependent: :destroy
has_rich_text :body
enum status: { active: 0, hidden: 1, deleted: 2 }
validates :body, presence: true, length: { maximum: 2_000 }
before_create :set_depth
after_create :update_parent_activity, :update_post_activity
scope :visible, -> { where(status: :active) }
scope :top_level, -> { where(parent_id: nil) }
scope :by_activity, -> { order(last_reply_at: :desc, created_at: :desc) }
def editable?
created_at > 30.minutes.ago
end
def edited?
edited_at.present?
end
private
def set_depth
self.depth = parent ? parent.depth + 1 : 0
end
def update_parent_activity
parent&.update_column(:last_reply_at, Time.current)
end
def update_post_activity
post.touch_activity!
end
end
# app/models/cp/board_reaction.rb
class Cp::BoardReaction < ApplicationRecord
belongs_to :reactable, polymorphic: true, counter_cache: :reactions_count
belongs_to :champion, class_name: "Cp::Champion"
ALLOWED_EMOJIS = %w[👍 ❤️ 😄 🎉 🙌 💯].freeze
validates :emoji, presence: true, inclusion: { in: ALLOWED_EMOJIS }
validates :champion_id, uniqueness: { scope: [:reactable_type, :reactable_id, :emoji] }
end
# app/models/cp/post_flag.rb
class Cp::PostFlag < ApplicationRecord
belongs_to :flaggable, polymorphic: true
belongs_to :reporter, class_name: "Cp::Champion"
belongs_to :resolved_by, class_name: "Cp::Champion", optional: true
enum reason: { spam: 0, inappropriate: 1, off_topic: 2, harassment: 3, other: 4 }
enum status: { pending: 0, resolved: 1, dismissed: 2 }
scope :pending, -> { where(status: :pending) }
end
# app/models/cp/user_block.rb
class Cp::UserBlock < ApplicationRecord
belongs_to :blocker, class_name: "Cp::Champion"
belongs_to :blocked, class_name: "Cp::Champion"
validates :blocked_id, uniqueness: { scope: :blocker_id }
validate :cannot_block_self
private
def cannot_block_self
errors.add(:blocked_id, "cannot block yourself") if blocker_id == blocked_id
end
end
# app/models/cp/hidden_content.rb
class Cp::HiddenContent < ApplicationRecord
belongs_to :champion, class_name: "Cp::Champion"
belongs_to :hideable, polymorphic: true
validates :hideable_id, uniqueness: { scope: [:champion_id, :hideable_type] }
end
# app/models/cp/moderation_action.rb
class Cp::ModerationAction < ApplicationRecord
belongs_to :moderator, class_name: "Cp::Champion"
belongs_to :target, polymorphic: true
enum action: { hide: 0, unhide: 1, lock: 2, unlock: 3, pin: 4, unpin: 5, escalate: 6 }
end
# app/models/cp/champion.rb (additions)
class Cp::Champion < ApplicationRecord
has_many :board_posts, class_name: "Cp::BoardPost", foreign_key: :author_id
has_many :board_comments, class_name: "Cp::BoardComment", foreign_key: :author_id
has_many :board_reactions, class_name: "Cp::BoardReaction"
has_many :post_flags, class_name: "Cp::PostFlag", foreign_key: :reporter_id
has_many :hidden_contents, class_name: "Cp::HiddenContent"
has_many :blocks_given, class_name: "Cp::UserBlock", foreign_key: :blocker_id
has_many :blocks_received, class_name: "Cp::UserBlock", foreign_key: :blocked_id
has_many :blocked_champions, through: :blocks_given, source: :blocked
has_many :blocked_by_champions, through: :blocks_received, source: :blocker
def blocked?(champion)
blocks_given.exists?(blocked_id: champion.id)
end
def hidden?(content)
hidden_contents.exists?(hideable: content)
end
end
Different locations use different logic to balance discovery with engagement:
| Location | Logic | Posts Shown |
|---|---|---|
| Community Board page | Latest activity (last_activity_at desc) |
All posts, paginated |
| Community Show page | Top 3-5 by popularity score | “Popular Discussions” section |
| Dashboard | Top 3 by popularity from user’s communities | Compact preview |
# Constants (hardcoded, can be adjusted in future)
TIME_DECAY_DAYS = 14
def popularity_score
age_in_days = (Time.current - created_at) / 1.day
time_factor = [1.0 - (age_in_days / TIME_DECAY_DAYS), 0].max
(reactions_count * 2) + (comments_count * 3) + (time_factor * 10)
end
Score Components:
Example Scores: | Post Age | Reactions | Comments | Score | |———-|———–|———-|——-| | 1 day | 5 | 10 | 10 + 30 + 9.3 = 49.3 | | 7 days | 20 | 50 | 40 + 150 + 5.0 = 195.0 | | 14+ days | 100 | 200 | 200 + 600 + 0 = 800.0 |
┌─────────────────────────────────────────────────────────────────────────┐
│ 📋 Nashville District Discussions [New Post +] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 📌 [PINNED] Welcome to the Nashville District Board! │
│ Posted by Sarah Chen • 2 weeks ago • 5 reactions • 12 comments │
│ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ Anyone know a good venue for a 50-person meetup? │
│ Posted by Mike Johnson • 2 hours ago • 8 reactions • 23 comments │
│ "Hey everyone! I'm trying to plan our Q2 event and looking for..." │
│ [See more] │
│ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ Congrats to Jane on her promotion! 🎉 │
│ Posted by Tom Wilson • 1 day ago • 15 reactions • 8 comments │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 💬 Popular Discussions [View All →] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Anyone know a good venue for a 50-person meetup? │ │
│ │ Mike Johnson • 23 comments │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Congrats to Jane on her promotion! 🎉 │ │
│ │ Tom Wilson • 8 comments │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Tips for hosting your first Champion event │ │
│ │ Sarah Chen • 15 comments │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 💬 Active Discussions │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 📍 Nashville: Anyone know a good venue for... [23 💬] │
│ 🎓 Music Business: Industry trends for 2026 [12 💬] │
│ 🏛️ Alumni Champions: Welcome new Champions! [45 💬] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
last_activity_at (active threads rise)BoardPost belongs to community and authorBoardPost status transitions work correctlyBoardPost edit window (30 min) enforcedBoardPost popularity_score calculationBoardPost last_activity_at updates on commentBoardComment threading works (unlimited depth)BoardComment depth calculated on createBoardComment edit window (30 min) enforcedBoardReaction uniqueness per user per emoji per contentPostFlag polymorphic association worksUserBlock prevents self-blockingHiddenContent uniqueness enforcededited_at timestamp| Feature | Sub-Phase |
|---|---|
| Per-community boards with admin toggle | 3.1 |
| Posts (40K chars, single image, rich text) | 3.2 |
| Comments (2K chars, rich text) | 3.2 |
| Collapsible threaded comments (show 2 levels) | 3.2 |
| 30-minute edit window with “Edited” indicator | 3.2 |
| Post truncation with “See more” | 3.2 |
| Community Guidelines reminder in composer | 3.2 |
| Emoji reactions (posts + comments) | 3.3 |
| Sort by latest activity | 3.3 |
| Popularity score for surfacing | 3.3 |
| Report/Flag content | 3.4 |
| Hide content (for self) | 3.4 |
| Block user | 3.4 |
| CL moderation (hide, lock, pin, escalate) | 3.5 |
| Moderation action log | 3.5 |
| National Alumni Champions board | 3.6 |
| Reply/reaction notifications | 3.7 |
| Admin discussion analytics dashboard | 3.8 |
| Feature | Notes |
|---|---|
| @mentions | Requires name resolution UI, notification integration |
| Search within boards | Full-text search across posts/comments |
| Bookmarks/Save posts | Save for later reading |
| Mute specific threads | Stop notifications for a thread |
| Additional sort options | Top, Controversial, etc. |
| Multiple images per post | Currently MVP is single image |
| User temp bans | 24hr, 7 day, permanent bans by CL |
| Advanced reporting analytics | Content trends, user patterns, exportable reports (basic metrics in 3.8) |
| Feature | Reason |
|---|---|
| Anonymous posting | Against community trust principles |
| Upvote/downvote | Feels like popularity contest |
| Algorithmic feed | Prioritize chronological + activity over “engagement” |
| Jobs/Career board | Separate Job Board feature |
| Mentorship board | Separate Mentorship Center feature |
After completing Phase 3:
roadmap_controller.rb with Phase 3 status| Question | Status | Decision |
|---|---|---|
| Board per community or separate model? | ✅ Resolved | Per community (no separate Board model) |
| National board implementation? | ✅ Resolved | Special community with national: true flag |
| Comment nesting depth? | ✅ Resolved | Unlimited, collapsible at depth 3+ |
| Rich text editor? | ✅ Resolved | ActionText (bold, italic, bullets, numbers, links) |
| Edit time window? | ✅ Resolved | 30 minutes with “Edited” indicator |
| Reactions? | ✅ Resolved | Emoji reactions (mirror MessageReaction) |
| Sorting? | ✅ Resolved | By last_activity_at (fresh threads rise) |
| Content limits? | ✅ Resolved | Posts 40K, Comments 2K |
| Images? | ✅ Resolved | Single image per post (MVP) |
| User controls? | ✅ Resolved | Report, Hide content, Block user |
| CL controls? | ✅ Resolved | Hide, Lock, Pin, Escalate |
| Surfacing algorithm? | ✅ Resolved | Popularity = (reactions×2) + (comments×3) + time_decay |
| Time decay period? | ✅ Resolved | 14 days (hardcoded constant) |
| Dashboard posts? | ✅ Resolved | 3 posts (compact) |
| Community show posts? | ✅ Resolved | 3-5 popular posts section |
| @mention implementation? | ⏸️ Deferred | Backlog item |
| Document | Purpose |
|---|---|
| ../../JOBS-TO-BE-DONE.md | Jobs C5, L3, L4 |
| ../phase-2/README.md | Community Leadership (moderation queue) |
| ../../development/DESIGN-GUIDELINES.md | UI patterns |
| ../../features/05-DISCUSSION-BOARDS.md | Feature details |
| config/faq.yml | Community Guidelines content |
A short reminder should appear in the post composer:
📋 Community Guidelines Reminder
• Be respectful and constructive
• Keep discussions relevant to the community
• No political or controversial content
• Celebrate wins, support struggles
• Report content that makes you uncomfortable
[Read full Community Guidelines →]
The full guidelines should be in the FAQ (Help page), covering:
This phase enables community-wide communication and marks a significant step toward building a thriving, safe Champion community.