Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/champion-portal/source/README.md. Use these sources (includingCHRIST_CENTERED__IDENTITY_STATEMENT.md) when writing specs or user-facing copy. Prefer quoting/paraphrasing over inventing new language.
Status: ✅ Complete Completed: February 2026 Estimated Effort: 3–4 weeks Prerequisites: Phase 11 Complete (News, Events fully functional)
Test Results: 3,582 runs, 9,178 assertions, 0 failures, 0 errors
All 4 sub-phases delivered as specified:
14.1 — Data Model & Backend Foundation
submitted: -1 and declined: -2 to Cp::NewsPost and Cp::Event status enumssubmitted_by_champion_id, decline_reason, submitted_at, declined_at columns to both content tablesCp::ContentSubmissionThread model (polymorphic content_type/content_id, champion FK, open/resolved status)Cp::ContentSubmissionMessage model (polymorphic sender, body, read_at tracking)submitted and declined statuses14.2 — Champion Submission Forms & UX
/news/submit (Cp::NewsSubmissionsController)/events/submit (Cp::EventSubmissionsController)?community_id= parameter/my-submissions (Cp::SubmissionsController)14.3 — Admin Submissions Queue & Review
/champions/content_submissions (Champions::ContentSubmissionsController)14.4 — Notifications & Attribution
submission_reply, submission_promoted, submission_published, submission_declinedCp::ContentSubmissionMailer with 3 email flows:
new_submission_notification (admin notification on champion submit)champion_reply_notification (admin notification on champion reply)submission_published_notification (champion celebration email)Cross-View Polish
bg-skyblue/30 for “your” bubbles, bg-white border for “theirs”Models: Cp::ContentSubmissionThread, Cp::ContentSubmissionMessage (+ additions to Cp::NewsPost, Cp::Event, Cp::Champion, Cp::Notification)
Controllers: Cp::NewsSubmissionsController, Cp::EventSubmissionsController, Cp::SubmissionsController, Champions::ContentSubmissionsController
Mailer: Cp::ContentSubmissionMailer (6 templates)
Views: 15+ new/modified view files across champion portal and admin
Tests: 50+ new tests across models, controllers, and mailer
None — all sub-phases implemented as specified.
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Status enum value for submitted |
submitted: -1 |
Negative values mean status < 0 = “not real content” — simple guard for all public queries |
| 2 | Decline handling | Add declined: -2 as a distinct status |
Keeps declined items cleanly separated from drafts. Same status < 0 guard excludes both submitted and declined from public views |
| 3 | Sub-phase execution | Combine sub-phases where sensible | No artificial boundaries — build what makes sense together |
| 4 | Timeline | No constraints | Work through all sub-phases as capacity allows |
| 5 | Community dropdown scope | Champion’s own communities only + “General” | Champions should not submit to communities they don’t belong to |
Currently, all content (news, events, photos) is created exclusively by admins. As the portal opens to a broader alumni audience, we need a way for any authenticated champion to suggest content — a news story they’ve heard about, an event they know is coming, etc.
This phase introduces a content submission pipeline: alumni submit via lightweight forms, admins review in a dedicated queue, and submissions can be promoted to real published content with full attribution to the original submitter.
The long-term content strategy has three levels:
Rather than building the full CL content creation suite (Level 2), this phase establishes Level 3 as a stepping stone. Any champion — including Community Leaders — can submit content ideas. This creates an immediate feedback loop (“What do alumni care about?”) while keeping admin control over published content quality.
Champion sees CTA → Fills form → Submission created (status: submitted)
↓
Admin notified → Reviews in Submissions Queue → Conversation thread if needed
↓
Admin promotes to draft → Edits as needed → Publishes with submitter attribution
OR
Admin declines with reason → Champion notified
| Sub-Phase | Name | Status | Effort |
|---|---|---|---|
| 14.1 | Data Model & Backend Foundation | ⬜ Not started | Medium |
| 14.2 | Champion Submission Forms & UX | ⬜ Not started | Medium |
| 14.3 | Admin Submissions Queue & Review | ⬜ Not started | Medium-High |
| 14.4 | Notifications & Attribution | ⬜ Not started | Medium |
Establish the data model for content submissions and the messaging thread system that supports admin-champion conversation about submissions.
Cp::NewsPost and Cp::Event records with a new submitted status value. This avoids a separate model and means admin can promote by simply changing status to draft, then editing and publishing normally.submitted: -1 and declined: -2 to existing status enums (negative values keep existing 0/1/2 values stable, and status < 0 provides a simple guard for “not real content”). Note: Rails integer enums support negative values.Cp::ContentSubmissionThread and Cp::ContentSubmissionMessage models (parallel to Support Threads pattern but isolated from CL support).cp_news_posts.status enumenum :status, {
declined: -2, # NEW — admin declined the submission
submitted: -1, # NEW — alumni submission awaiting review
draft: 0,
published: 1,
archived: 2
}, prefix: true
cp_events.status enumenum :status, {
declined: -2, # NEW — admin declined the submission
submitted: -1, # NEW — alumni submission awaiting review
draft: 0,
published: 1,
archived: 2
}, prefix: true
Key insight: All public-facing queries can use where('status >= 0') or rely on existing named scopes (published, draft) which already filter to status >= 0 values. Both submitted (-1) and declined (-2) are invisible to champions by default.
cp_news_posts fieldst.bigint :submitted_by_champion_id # FK to cp_champions — who submitted this
t.text :decline_reason # If admin declines the submission
t.datetime :submitted_at # When the submission was made
t.datetime :declined_at # When the submission was declined
cp_events fieldst.bigint :submitted_by_champion_id # FK to cp_champions — who submitted this
t.text :decline_reason # If admin declines the submission
t.datetime :submitted_at # When the submission was made
t.datetime :declined_at # When the submission was declined
cp_content_submission_threadscreate_table :cp_content_submission_threads do |t|
t.string :content_type, null: false # "news_post" or "event"
t.bigint :content_id, null: false # FK to cp_news_posts or cp_events
t.bigint :cp_champion_id, null: false # The submitting champion
t.integer :status, default: 0, null: false # open(0), resolved(1)
t.timestamps
end
add_index :cp_content_submission_threads, [:content_type, :content_id], unique: true
add_index :cp_content_submission_threads, :cp_champion_id
cp_content_submission_messagescreate_table :cp_content_submission_messages do |t|
t.bigint :cp_content_submission_thread_id, null: false
t.string :sender_type, null: false # "Cp::Champion" or "User"
t.bigint :sender_id, null: false
t.text :body, null: false
t.datetime :read_at # For read tracking
t.timestamps
end
add_index :cp_content_submission_messages, :cp_content_submission_thread_id
Cp::NewsPost additionsbelongs_to :submitted_by_champion, class_name: "Cp::Champion", optional: true
scope :submitted, -> { where(status: :submitted) }
scope :declined, -> { where(status: :declined) }
scope :pending_review, -> { submitted } # Submitted = awaiting first admin action
scope :real_content, -> { where('status >= 0') } # Excludes submitted & declined
def submitted_content?
submitted_by_champion_id.present?
end
def decline!(reason:)
update!(status: :declined, decline_reason: reason, declined_at: Time.current)
end
def promote_to_draft!
update!(status: :draft)
end
Cp::Event additionsSame pattern as NewsPost.
Cp::ContentSubmissionThreadbelongs_to :champion, class_name: "Cp::Champion"
has_many :messages, class_name: "Cp::ContentSubmissionMessage", dependent: :destroy
enum :status, { open: 0, resolved: 1 }, prefix: true
belongs_to :content, polymorphic: true # Using content_type + content_id
scope :needs_response, -> { ... } # Last message from champion
scope :for_content, ->(type, id) { where(content_type: type, content_id: id) }
Cp::ContentSubmissionMessagebelongs_to :thread, class_name: "Cp::ContentSubmissionThread",
foreign_key: :cp_content_submission_thread_id
belongs_to :sender, polymorphic: true # Cp::Champion or User
scope :unread, -> { where(read_at: nil) }
scope :from_champion, -> { where(sender_type: "Cp::Champion") }
scope :from_staff, -> { where(sender_type: "User") }
submitted: -1 and declined: -2 to news_post and event status enumssubmitted_by_champion_id, decline_reason, submitted_at, declined_at to both content tablescp_content_submission_threads and cp_content_submission_messages tablesCp::ContentSubmissionThread and Cp::ContentSubmissionMessage models created with validationssubmitted status records excluded from all public-facing queriessubmitted and declined statusesreal_content scope added (status >= 0) for easy guard queriessubmitted and declined records excluded from visible_to(champion) scopeBuild the champion-facing submission forms and integrate CTAs into relevant content browsing areas.
CTAs appear in two locations per content type:
News:
/news) — banner or button near topEvents:
/events) — banner or button near topCTA Design:
?community_id=X paramRoute: GET /news/submit → Cp::NewsSubmissionsController#new
Fields:
| Field | Type | Required | Notes |
|——-|——|———-|——-|
| Title | Text input | Yes | “What’s the headline?” |
| Summary | Textarea | Yes | “Tell us about it (2-3 sentences)” — maps to excerpt |
| Details | Textarea | No | “Any additional details?” — maps to full_content |
| Link | URL input | No | “Link to an article or website about this” |
| Community | Select dropdown | No | Pre-populated from champion’s own communities only + “All Alumni (General)” option. Pre-selected if community_id param present. |
Route: GET /events/submit → Cp::EventSubmissionsController#new
Fields: | Field | Type | Required | Notes | |——-|——|———-|——-| | Title | Text input | Yes | “What’s the event called?” | | Description | Textarea | Yes | “Tell us about this event” | | Start date/time | Datetime picker | Yes | “When does it start?” | | End date/time | Datetime picker | No | “When does it end?” | | Venue name | Text input | No | “Where is it?” | | Venue address | Text input | No | “Street address” | | Virtual? | Checkbox | No | Toggle for virtual event | | Virtual URL | URL input | No | Shown when virtual checked | | RSVP link | URL input | No | “Link to register or RSVP” | | Community | Select dropdown | No | Same as news form (champion’s own communities only + “General”) |
Cp::NewsPost or Cp::Event with:
status: :submittedsubmitted_by_champion_id: current_champion.idsubmitted_at: Time.currentauthor: current_champion (polymorphic author)Cp::ContentSubmissionThread linked to the contentRoute: GET /my-submissions → Cp::SubmissionsController#index
Champions can see their past submissions with current status:
status: :submitted) — “Under review”status: :draft) — “Being prepared for publication” (admin promoted it)status: :published) — “Published!” with link to the live contentstatus: :declined) — “Not published” with reason if providedEach submission links to its conversation thread so the champion can reply to admin questions.
/news/submit (authenticated champions only)/events/submit (authenticated champions only)?community_id= parametersubmitted status/my-submissionsBuild the admin-facing review queue, conversation reply capability, and promote/decline workflows.
Route: GET /champions/content_submissions → Champions::ContentSubmissionsController#index
Layout: Dedicated admin page listing all submissions awaiting review.
Tabs/Filters:
| Tab | Description | Badge |
|—–|————-|——-|
| Pending Review | status: :submitted | Count badge |
| In Conversation | status: :submitted + thread has admin reply but not yet promoted/declined | Count badge |
| Promoted | status: :draft or status: :published with submitted_by_champion_id present (last 30 days) | — |
| Declined | status: :declined (last 30 days) | — |
List View Columns:
Route: GET /champions/content_submissions/:id → Champions::ContentSubmissionsController#show
Layout:
Actions:
| Action | Effect |
|——–|——–|
| Promote to Draft | Changes status from submitted → draft. Opens the standard content edit form so admin can refine before publishing. |
| Publish Directly | Changes status from submitted → published, sets published_at. For submissions that are ready as-is. |
| Decline | Prompts for decline reason. Changes status to declined, stores reason, sets declined_at. Notifies champion. |
| Reply | Adds a message to the conversation thread. Notifies champion in-app. |
Follows Support Thread pattern:
When admin clicks “Promote to Draft”:
submitted to draft/champions/content_submissionsportal_admin or higherWire up notifications for the complete submission lifecycle and add submitter attribution to published content.
| Event | Recipient | Channel | Message |
|---|---|---|---|
| New submission | Admins + support responders | Email + in-app | “New [news/event] submission from [Champion Name]” |
| Admin replies to thread | Submitting champion | In-app | “[Admin Name] replied to your submission” |
| Champion replies to thread | Admins + support responders | In-app | “[Champion Name] replied on their [news/event] submission” |
| Submission promoted to draft | Submitting champion | In-app | “Your [news/event] submission is being prepared for publication!” |
| Submission published | Submitting champion | In-app + email | “Your [news/event] has been published!” |
| Submission declined | Submitting champion | In-app | “Update on your [news/event] submission” (with reason) |
When a published news post or event was originally submitted by a champion:
Implementation: Use submitted_by_champion_id presence to show attribution. The author field may be changed to the admin who published it (or kept as the champion — admin’s choice during editing).
New submission notification (to admin):
Submission published notification (to champion):
Both submitted: -1 and declined: -2 must be excluded from all champion-facing content queries. Verify that:
Cp::NewsPost.published excludes submitted and declinedCp::NewsPost.visible_to(champion) excludes submitted and declinedCp::Event.published excludes submitted and declinedCp::Event.upcoming excludes submitted and declinedSince both statuses are negative integers and existing scopes filter by status: :published or specific status >= 0 values, this should work automatically. The new real_content scope (where('status >= 0')) provides an explicit guard. Must be verified during implementation.
Two distinct concepts:
author (polymorphic) — who is credited as author of the published content. Admin may change this during editing.submitted_by_champion_id — who originally submitted the content idea. Never changes. Used for attribution and submission history.Submission forms should be mobile-friendly:
This architecture supports the eventual CL content creation flow (Level 2):
draft status (bypassing admin review) if given permission