alumni_lookup

Model Relationships & ActiveRecord Associations

This document details the critical ActiveRecord model relationships in the alumni_lookup application. Getting these associations wrong will cause ActiveRecord::ConfigurationError exceptions.

See Also: REPO_OVERVIEW.md for complete entity relationship diagrams and architecture.

🎯 Quick Reference

Model Association Target Key Details
EngagementActivity belongs_to :alumni Alumni ⚠️ NOT :alumnus
EngagementBatchLog belongs_to :api_key ApiKey Optional, tracks which key made the sync
Alumni has_many :degrees Degree Via buid
Alumni has_many :educations Education Via buid
Alumni has_many :education_areas_of_study EducationAreaOfStudy Through educations
Alumni has_many :employments Employment Via buid, -> { by_recency }, ⚠️ dependent: :destroy (real FK on employments.buid β€” without it, destroying an alum raises)
Employment belongs_to :alumni Alumni Via buid β†’ alumni.buid. ⚠️ NOT :alumnus
Alumni has_many :engagement_activities EngagementActivity Via buid
Alumni has_many :champion_signups ChampionSignup Via buid
ChampionSignup belongs_to :alumni Alumni Via buid
ChampionSignup has_many :signup_events ChampionSignupEvent ⚠️ dependent: :destroy
ChampionSignup has_many :crm_data_changes CrmDataChange Via champion_signup_id (nullable). ⚠️ dependent: :nullify β€” an exported change can’t be recalled from Advancement Services, so it outlives the signup (Phase 22.3)
ChampionSignup merged_into_id β†’ ChampionSignup ChampionSignup Self-referential FK, column only β€” no belongs_to (Phase 27.1). Set on a soft-deleted row by ChampionSignupMerger to name the row that absorbed it. Read exclusively through ChampionSignup.follow_merge, which must also assert the target is active; a bare association would let a caller land on a deleted row
ChampionSignup has_many :device_sessions ChampionSignupSession Remembered devices (Phase 27.3). ⚠️ dependent: :destroy covers a real destroy, but the app soft-deletes, so that never fires on the normal path β€” use ChampionSignup#soft_delete!, which destroys sessions before stamping deleted_at. Those rows hold a user agent and an IP, so keeping them is retaining PII after a deletion request. A merge deliberately does the opposite and keeps them: they resolve to the surviving row via follow_merge
ChampionSignupSession belongs_to :champion_signup ChampionSignup May point at a soft-deleted row after a merge. Never read champion_signup directly for authorization β€” use #champion_signup_resolved, which routes through ChampionSignup.follow_merge and returns nil for a row deleted outright. Stores token_digest (SHA-256), never the raw token
ChampionSignup has_many :signin_codes ChampionSignupSigninCode Six-digit sign-in codes (Phase 27.7). ⚠️ dependent: :destroy, with the same soft-delete caveat as device_sessions above β€” ChampionSignup#soft_delete! is what actually clears them. issue! supersedes any outstanding code for the signup, so at most one is ever live
ChampionSignupSigninCode belongs_to :champion_signup ChampionSignup Stores a digest, never the six digits. One-use, 15-minute life, five-attempt cap β€” the attempt cap is the real bound, since a 1,000,000-key space is small enough that a rate limit only slows a walk through it. The fifth credential that has to survive a merge (Β§27.1), and the only one that needs no follow_merge call β€” issue! and verify both key off ChampionSignup.find_active_by_email, i.e. the newest active row for the address, which is the same row ChampionSignupMerger makes the merge target. The two agree by construction. Preserve that if either side is ever re-keyed: the trap is changing one of them to resolve by id
Cp::Champion has_many :activity_events Cp::ActivityEvent Via cp_champion_id
Cp::Champion has_many :crm_data_changes CrmDataChange Via cp_champion_id (nullable)
Cp::ActivityEvent belongs_to :champion Cp::Champion Via cp_champion_id
Cp::BoardPost has_one :seeded_question_exposure Cp::SeededQuestionExposure ⚠️ dependent: :destroy (NOT :nullify β€” FK is NOT NULL)
Cp::SeededQuestionExposure belongs_to :board_post Cp::BoardPost Via board_post_id (NOT NULL)
Cp::CareerResource has_one_attached :document ActiveStorage PDF document (validated as PDF)
Cp::Event β€” β€” career_event boolean (default: false), scope :career_events
Cp::Champion has_many :role_idea_packs Cp::RoleIdeaPack Via cp_champion_id, dependent: :destroy
Cp::RoleIdea has_many :pack_items Cp::RoleIdeaPackItem Via cp_role_idea_id
Cp::RoleIdeaPack belongs_to :champion Cp::Champion Via cp_champion_id
Cp::RoleIdeaPack has_many :items Cp::RoleIdeaPackItem Via cp_role_idea_pack_id, dependent: :destroy
Cp::RoleIdeaPackItem belongs_to :role_idea_pack Cp::RoleIdeaPack Via cp_role_idea_pack_id
Cp::RoleIdeaPackItem belongs_to :role_idea Cp::RoleIdea Via cp_role_idea_id
Cp::ContentSubmissionThread belongs_to :champion Cp::Champion Via cp_champion_id
Cp::ContentSubmissionThread belongs_to :content Cp::NewsPost / Cp::Event Polymorphic (content_type, content_id)
Cp::ContentSubmissionThread has_many :messages Cp::ContentSubmissionMessage Via content_submission_thread_id, dependent: :destroy
Cp::ContentSubmissionMessage belongs_to :thread Cp::ContentSubmissionThread Via content_submission_thread_id
Cp::ContentSubmissionMessage belongs_to :sender Cp::Champion / User Polymorphic (sender_type, sender_id)
Cp::Champion has_many :content_submission_threads Cp::ContentSubmissionThread Via cp_champion_id
Cp::Champion has_many :milestones Cp::Milestone Via cp_champion_id, dependent: :destroy
Cp::Milestone belongs_to :champion Cp::Champion Via cp_champion_id
Cp::FeatureFlag belongs_to :last_toggled_by User Optional, via last_toggled_by_id β€” who last flipped the flag (Phase 20.1)
Cp::NewsPost has_one :submission_thread Cp::ContentSubmissionThread Polymorphic (as: :content)
Cp::Event has_one :submission_thread Cp::ContentSubmissionThread Polymorphic (as: :content)
Degree belongs_to :major Major Via major_code
Education belongs_to :alumni Alumni Via buid
Education belongs_to :granting_college College Optional, via granting_school_code -> college_code
Education belongs_to :current_college College Optional, via current_school_code -> college_code
Education has_many :areas_of_study EducationAreaOfStudy Via education_id
EducationAreaOfStudy belongs_to :education Education Via education_id
EducationAreaOfStudy belongs_to :current_institutional_unit College Optional, via current_institutional_unit_code -> college_code
Major belongs_to :college College Via college_code

πŸ”— Association Chain

Alumni β†’ Degrees β†’ Major β†’ College
  ↓        ↓        ↓       ↓
 buid   major_code  college_code

Alumni β†’ Educations β†’ EducationAreasOfStudy
  ↓         ↓               ↓
 buid   source_education_id person_area_of_study_id

Alumni β†’ ChampionSignups (prospect/champion status)
  ↓         ↓
 buid    status enum (1-5)

🧩 Read-Path Presenter: Alumni::EducationProfile

Derived education data (display strings, UG/GR splits, college/major rollups, gradyear lists) lives in app/services/alumni/education_profile.rb, NOT on the Alumni model itself. Views, helpers, and matching services should read through the presenter rather than walking alumni.degrees or alumni.educations directly.

profile = alumni.education_profile      # memoized; auto-infers privacy from champion
profile.all_entries                     # [Entry] β€” Education-first, Degree fallback
profile.recent                          # Entry (most recent)
profile.undergraduate / profile.graduate
profile.college_codes / profile.major_codes / profile.grad_years
profile.display_summary                 # e.g. "B.B.A. in Marketing"
profile.to_export_hash                  # legacy ug_*/gr_* keys for check-in copy

Source preference per alumni: if any Education rows exist, derive from Education + EducationAreaOfStudy; otherwise synthesize from degrees -> majors -> colleges. Entry#degree_level is derived at read time via Education.level_for(degree_code) for resilience against fixtures and stale rows.

Privacy: auto-inferred from alumni.champion.education_privacy unless viewer: matches the displayed champion (self-view never redacts). When hidden?, rollups are empty and display_summary returns β€œBelmont University graduate”.

Alumni#recent_degree and Alumni#graduation_years delegate here. Entry#as_json preserves the V1 API contract ({major_desc, college_name, degree_code, degree_date} with college_name mapped to college_name_short).

profile.source returns :education, :degree_fallback, or :none β€” used by both API endpoints (Api::AlumniController and Api::V1::AlumniSearchController) to emit a top-level _source advisory field so downstream consumers can observe the rollout.

πŸ“‹ Model Definitions

EngagementActivity

class EngagementActivity < ApplicationRecord
  belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true
  # ⚠️ CRITICAL: Association name is :alumni (NOT :alumnus)
end

Alumni

class Alumni < ApplicationRecord
  # Existing associations
  has_many :degrees, primary_key: :buid, foreign_key: :buid
  has_many :engagement_activities, foreign_key: :buid, primary_key: :buid
  
  # Champion/Prospect system (Added August 2025)
  # NOTE the `active` scope β€” soft-deleted signups are never in this association.
  has_many :champion_signups, -> { active }, foreign_key: :buid, primary_key: :buid
  enum prospect_status: { not_prospect: 0, prospect: 1 }

  # Phase 25 β€” the ONE signup that speaks for this alum (newest active), and the
  # read layer for everything it collected. Never join to champion_signups for
  # display; an alum can hold several and you will render them twice.
  def current_champion_signup; end   # -> ChampionSignup or nil, memoized
  def signup_profile; end            # -> Alumni::SignupProfile
  
  # Contact ID for CRM integration (Added August 2025)
  validates :contact_id, format: { with: /\AC-\d{9}\z/, message: "must be in format C-000000000" }, allow_blank: true
  validates :contact_id, uniqueness: true, allow_blank: true
  
  # Helper methods for champion status
  def manually_flagged_prospect?
    prospect_status == 'prospect'
  end
  
  def automatic_prospect?
    has_champion_signup? && !has_completed_champion_signup?
  end
end

ChampionSignup (Added August 2025)

class ChampionSignup < ApplicationRecord
  belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true
  
  enum status: {
    started: 1,
    completed_questions: 2,
    selected_role: 3,
    interests: 4,
    zip_code: 5  # Complete champion designation
  }
  
  scope :completed, -> { where(status: 5) }
  scope :in_progress, -> { where(status: 1..4) }
  
  # Merge resolution (Phase 27.1). A soft-deleted row absorbed by a merge carries
  # merged_into_id; one soft-deleted by #destroy does not, and must stay
  # unresolvable. Every credential resolver goes through follow_merge, so the
  # access code, the profile token, and the device session can never disagree
  # about where a merged-away row went.
  def self.follow_merge(signup); end   # -> the active row, or nil
  def merged?; end                     # deleted AND absorbed, vs. plain delete

  # Duplicate detection. ONE key as of Phase 28.1 β€” a partial unique index on
  # LOWER(email) WHERE deleted_at IS NULL means an address names at most one
  # active row, so the email-keyed scope, predicates and merge were removed
  # rather than left as queries that could never return anything.
  #
  # Two different addresses on one BUID is still ordinary, and at 95% BUID
  # coverage is now the only duplicate shape the app can produce.
  def self.active_by_email(email); end # THE definition of "rows this email covers"
                                       # (at most one, since 28.1)
  scope :duplicates_by_buid

  # Data integrity notes:
  # - lifestage_interest field uses comma-separated keywords format
  # - Valid values: 'almost-alumni', 'young-alumni', 'tower-society', 'families', 'other'
  # - v2 public signup additions include free-text verification/career fields:
  #   belmont_background (text) and company (string)
  # - See docs/features/CHAMPION_SIGNUP_SYSTEM.md for data details
end

Cp::ActivityEvent (Added December 2025)

class Cp::ActivityEvent < ApplicationRecord
  belongs_to :champion, class_name: "Cp::Champion", foreign_key: :cp_champion_id
end
class CrmDataChange < ApplicationRecord
  belongs_to :alumni, optional: true
  belongs_to :cp_champion, class_name: "Cp::Champion", optional: true
  # Phase 22.3 β€” traces a change back to the signup that produced it, exactly as
  # cp_champion_id does for the belmontalum.com portal. Nullable: import- and
  # staff-sourced changes have no signup.
  belongs_to :champion_signup, optional: true
end

Both product FKs are nullable and mutually exclusive in practice β€” cp_champion_id is set by the held portal flow, champion_signup_id by ChampionSignupCrmLogger. change_source (champion_portal vs champion_signup) is the authoritative discriminator; the FKs exist for traceability, not for deciding which product it came from.

Degree

class Degree < ApplicationRecord
  belongs_to :major, foreign_key: :major_code, primary_key: :major_code
  # ⚠️ NO direct college association - must go through major
end

Education (Added April 2026, Phase 18.2)

class Education < ApplicationRecord
  belongs_to :alumni, foreign_key: :buid, primary_key: :buid
  belongs_to :granting_college, class_name: "College",
             foreign_key: :granting_school_code, primary_key: :college_code,
             optional: true
  belongs_to :current_college, class_name: "College",
             foreign_key: :current_school_code, primary_key: :college_code,
             optional: true

  has_many :areas_of_study, class_name: "EducationAreaOfStudy", dependent: :destroy

  # Phase 18.8: per-enrollment in-progress data lives here, NOT on alumni.
  #   expected_graduation_year (integer) β€” only meaningful when date_issued IS NULL
  #   student_status (string)            β€” "pending" | "withdrawn" | "awarded" | nil
  # The `in_progress` scope is the single source of truth for current enrollment:
  #   scope :in_progress, -> { where(date_issued: nil)
  #                              .where("educations.student_status IS NULL
  #                                      OR educations.student_status = 'pending'") }
  # `Education::ENROLLMENT_COLLEGE_CODE_SQL` is the canonical COALESCE that
  # resolves an in-progress row's college (current_school_code with NULL/'00'
  # stripped, falling back to granting_school_code).
end

EducationAreaOfStudy (Added April 2026, Phase 18.2)

class EducationAreaOfStudy < ApplicationRecord
  belongs_to :education
  belongs_to :current_institutional_unit, class_name: "College",
             foreign_key: :current_institutional_unit_code,
             primary_key: :college_code, optional: true

  has_one :alumni, through: :education
end

major_code (Phase 18.9): Nullable, indexed column sourced from the CRM β€œArea of Study: External Id” β€” the program (major) code, valid for majors, minors, and concentrations. Alumni.filter_by_major(code) matches alumni via education_areas_of_study.major_code joined through educations (no longer the frozen degrees table).

Major

class Major < ApplicationRecord
  belongs_to :college, foreign_key: :college_code, primary_key: :college_code
  has_many :degrees, foreign_key: :major_code, primary_key: :major_code
end

College

class College < ApplicationRecord
  has_many :majors, foreign_key: :college_code, primary_key: :college_code
  has_many :degrees, through: :majors
end

Industry (Added July 2026, Phase 23.1) β€” not an ActiveRecord model

class Industry
  ALL = %w[healthcare technology education finance music film_tv ...].freeze
end

There is no industries table and no associations. Industry is a shared taxonomy β€” the values persist as plain strings on three columns across two apps: champion_signups.industry, cp_champions.industry, and cp_communities.industry.

Extracted in Phase 23.1 from Cp::Champion::INDUSTRIES, because the live alumnichampions.com signup flow was reading a constant off the frozen Alumni Network’s member model β€” an active surface importing from a frozen one.

There is exactly one copy of this list. Phase 23.4 deleted Cp::Champion::INDUSTRIES; both the member model’s validation and the Alumni Network Admin’s communities filter now read ::Industry::ALL. The 23.1 drift test is replaced by an assertion that no private copy can reappear β€” if that assertion can be written again, a second copy is back and that is the bug.

⚠️ Note the :: prefix. Inside module Cp, a bare Industry resolves lexically to Cp::Industry first; the failed autoload re-evaluates the class body and surfaces as an unrelated-looking error. Every reference from a namespaced surface into the shared layer needs it.

Remaining gaps:

Stored values are the underscored keys (film_tv), not titleized labels. The signup form’s <select> submits the key and humanizes only what the alum reads, so "Film TV" is a display string that must never reach the column.

Affinity β€” a shared model that owns its category vocabulary (Phase 23.3 Β§6.2)

Affinity is an ordinary ActiveRecord model (primary key affinity_code), but it also carries two constants that used to live in the apps that read them:

Affinity::SIGNUP_EXCLUDED_CATEGORIES     # read by Signup::SignupsController
Affinity::QUESTION_TARGETABLE_CATEGORIES # read by Cp::SeededQuestion

They answer different questions and are deliberately not collapsed into one list. They live together because Affinity is the thing they are about β€” add a category and this is the one file telling you what to reconsider.

IdentityField (Added July 2026, Phase 23.3) β€” not an ActiveRecord model

The same person can be described by three tables, using different names for the same field:

Canonical (alumni vocabulary) alumni cp_champions champion_signups
pref_name pref_name pref_first_name β€” none
maiden_name maiden_name college_last_name maiden_name
street β€” none street_address street (+ legacy address)
zip zip (limit 10) zip_code zip_code

IdentityField::MAP records the full mapping; .canonical(source, column) and .column_for(source, canonical) translate between vocabularies. nil means the source genuinely has no column for the concept β€” a recorded gap, not an omission.

Why it exists: Cp::ProfileChange::HIGH_PRIORITY_FIELDS declared pref_name and maiden_name while the callback feeding it wrote pref_first_name and college_last_name, so for_field("pref_name") returned zero rows and always would. Cp::ProfileChange now translates at query time.

Three traps it deliberately does not resolve:

Deciding which record is true when the three disagree is Phase 26. This is its prerequisite, not its replacement.

πŸ”„ Common Join Patterns

βœ… Correct Joins

# Get engagement activities with alumni info
EngagementActivity.joins(:alumni)

# Filter engagement activities by college (requires nested joins)
EngagementActivity.joins(alumni: { degrees: { major: :college } })
                  .where(colleges: { college_code: 'CS' })

# Filter alumni by graduation year
Alumni.joins(:degrees)
      .where("CASE WHEN EXTRACT(MONTH FROM degrees.degree_date) >= 6 
              THEN EXTRACT(YEAR FROM degrees.degree_date) + 1 
              ELSE EXTRACT(YEAR FROM degrees.degree_date) END = ?", 2024)

# Get alumni with college information
Alumni.joins(degrees: { major: :college })

# Get alumni with new education model data
Alumni.joins(:educations)

# Join education rows to areas of study
Education.joins(:areas_of_study)

# Filter alumni by champion status (Phase 25)
# Subquery, NOT joins β€” champion_signups is one-to-many, and a join lists an alum
# once per matching signup in the results, the count AND the CSV export.
Alumni.filter_by_alumni_status('completed_form')  # has a completed signup
Alumni.filter_by_alumni_status('no_form')         # does not

# Manual prospect flag β€” a staff judgment, independent of form completion
Alumni.filter_by_manual_prospect('1')

# Location and affinity search resolve through signup data (Phase 25)
Alumni.filter_by_district('nashville')   # newest signup ZIP over alumni.zip
Alumni.filter_by_affinities('BASEBALL')  # alumni_affinities OR signup affinity_codes

❌ Common Mistakes

# WRONG: Association name
EngagementActivity.joins(:alumnus)  # Should be :alumni

# WRONG: Skipping major in college join
EngagementActivity.joins(alumni: { degrees: :college })  # Missing major link

# WRONG: Direct college association on degrees
Degree.joins(:college)  # Degrees don't directly associate with colleges

# WRONG: Skipping education hop
Alumni.joins(:education_areas_of_study)  # Must use through association or join educations first

# WRONG: Ambiguous column references in complex joins (Added August 2025)
Alumni.joins(:champion_signups, degrees: { major: :college })
      .where(status: 5)  # Should be champion_signups.status = 5

🚨 Error Messages & Solutions

Can't join 'EngagementActivity' to association named 'alumnus'

Can't join 'Degree' to association named 'college'

Column ambiguity errors in complex joins (Added August 2025)

πŸ§ͺ Testing Associations in Rails Console

# Verify the association chain works
alumni = Alumni.first
alumni.degrees.first&.major&.college

# Test engagement activity association  
activity = EngagementActivity.first
activity.alumni

# Test college filtering
EngagementActivity.joins(alumni: { degrees: { major: :college } })
                  .where(colleges: { college_code: 'CS' })
                  .count

# Test champion signup associations (Added August 2025)
champion_signup = ChampionSignup.first
champion_signup.alumni

# Test prospect filtering
Alumni.where(prospect_status: 1).count

# Phase 25 β€” the signup read layer
alum = Alumni.find_by(buid: "B00123456")
alum.current_champion_signup          # newest active signup
alum.signup_profile.location          # effective location + provenance
alum.signup_profile.affinities        # both sources, one list
alum.signup_profile.interest_spectrum # four-area lean

πŸ’Ύ Database Keys


πŸ”— Alumni Portal Role Dashboard Models (Phase 11)

Cp::RoleIdea

Content model for actionable role-aligned suggestions shown on Champion dashboards.

# Enums
enum :status, { draft: 0, active: 1, paused: 2, archived: 3 }, prefix: :status
ROLES = Cp::Champion::CHAMPION_ROLES  # community_builder, digital_ambassador, etc.
TARGETS = %w[champion community_leader]

# Key scopes
scope :active,                -> { where(status: :active) }
scope :for_role(role),        -> { where(role: role) }
scope :for_champions,         -> { where(target: "champion") }
scope :for_community_leaders, -> { where(target: "community_leader") }

# Key methods
idea.render_body(champion)   # Interpolates , , 
idea.render_title(champion)  # Same interpolation for title
idea.community_specific?     # true if cta_route starts with "community:"

Cp::RoleIdeaPack

Daily pack of role ideas generated for each Champion.

# Associations
belongs_to :champion, class_name: "Cp::Champion", foreign_key: :cp_champion_id
has_many :items, class_name: "Cp::RoleIdeaPackItem", dependent: :destroy

# Key scopes
scope :for_date(date), -> { where(pack_date: date) }
scope :recent,         -> { where("pack_date >= ?", 30.days.ago) }

# Key methods
pack.primary_idea      # Returns RoleIdea at position 0
pack.secondary_ideas   # Returns array of RoleIdea objects at position 1+

Cp::RoleIdeaPackItem

Join model linking packs to individual ideas with position ordering.

# Associations
belongs_to :role_idea_pack, class_name: "Cp::RoleIdeaPack"
belongs_to :role_idea,      class_name: "Cp::RoleIdea"

# Validations
validates :position, presence: true
validates :cp_role_idea_id, uniqueness: { scope: :cp_role_idea_pack_id }

πŸ”— Alumni Portal Connection Models (Phase 10)

Cp::Connection

Represents a mutual connection between two Champions. Uses canonical pair ordering (champion_a_id < champion_b_id) to ensure uniqueness without storing both directions.

# Associations
belongs_to :champion_a,        class_name: "Cp::Champion"
belongs_to :champion_b,        class_name: "Cp::Champion"
belongs_to :connection_request, class_name: "Cp::ConnectionRequest", optional: true
belongs_to :message_thread,     class_name: "Cp::MessageThread"
belongs_to :disconnected_by,    class_name: "Cp::Champion", optional: true

# Key scopes
scope :active,           -> { where(disconnected_at: nil) }
scope :for_champion(c),  -> { where(champion_a_id: c.id).or(where(champion_b_id: c.id)) }
scope :between(a, b),    -> { # sorts IDs, finds exact canonical pair }

# Key methods
Connection.connected?(a, b)      # Boolean
Connection.create_between!(a, b) # Handles canonical ordering
connection.disconnect!(by_champion)
connection.other_champion(champion)

Cp::ConnectionRequest

A directional request from one Champion to another with a required type and message.

# Enum
enum :status, { pending: 0, accepted: 1, ignored: 2, cancelled: 3 }

# Constants
CONNECTION_TYPES = %w[say_hi career_advice networking]

# Associations
belongs_to :requestor, class_name: "Cp::Champion"
belongs_to :requestee, class_name: "Cp::Champion"

# Key scopes
scope :sent_today_by(champion),     -> { # today's requests by requestor }
scope :received_today_by(champion), -> { # today's pending for requestee }

# Key methods
request.accept!   # Sets status + responded_at
request.ignore!   # Silent ignore
request.cancel!   # Cancels own request
ConnectionRequest.pending_between?(a, b)

Champion Connection Associations

# On Cp::Champion
has_many :sent_connection_requests,     foreign_key: :requestor_id
has_many :received_connection_requests, foreign_key: :requestee_id
has_many :connections_as_a,             foreign_key: :champion_a_id
has_many :connections_as_b,             foreign_key: :champion_b_id

# Key methods
champion.connected_to?(other)
champion.connection_with(other)
champion.active_connections
champion.connected_champion_ids  # cached
champion.connection_open_to_types
champion.connections_paused?
champion.daily_request_cap_reached?

MessageThread Connection Additions

# On Cp::MessageThread
scope :connection_threads, -> { where(thread_type: "connection") }
scope :support_threads,    -> { where(thread_type: "support") }
thread.connection?  # true if thread_type == "connection"
thread.support?     # true if thread_type == "support"

ContentSubmissionThread (Phase 14)

class Cp::ContentSubmissionThread < ApplicationRecord
  belongs_to :champion, class_name: "Cp::Champion"
  belongs_to :content, polymorphic: true   # Cp::NewsPost or Cp::Event
  has_many :messages, class_name: "Cp::ContentSubmissionMessage",
           foreign_key: :content_submission_thread_id, dependent: :destroy

  enum :status, { open: 0, resolved: 1 }
end

ContentSubmissionMessage (Phase 14)

class Cp::ContentSubmissionMessage < ApplicationRecord
  belongs_to :thread, class_name: "Cp::ContentSubmissionThread",
             foreign_key: :content_submission_thread_id
  belongs_to :sender, polymorphic: true   # Cp::Champion or User (staff)
  # read_at: datetime for read tracking
end

🎯 Journey Engine & Tier Detection (Phase 13)

Cp::Tierable (Concern on Cp::Champion)

app/models/concerns/cp/tierable.rb β€” Included in Cp::Champion. Provides journey stage computation and engagement tier detection.

# Journey stages (stored in journey_stage integer column, default 0)
JOURNEY_STAGES = {
  just_arrived:       0,  # Account < 7 days, no activity
  getting_oriented:   1,  # Account >= 7 days, minimal activity
  exploring:          2,  # Some activity but profile < 75% complete
  building:           3,  # Profile >= 75%, < 2 communities
  connecting:         4,  # 2+ communities, < 3 connections
  contributing:       5,  # 3+ connections, no contributions
  champion_ready:     6,  # Has contributions, not yet a Champion
  champion:           7,  # Is a verified Champion
}

# Thresholds (constants on concern)
PROFILE_COMPLETION_THRESHOLD    = 75
COMMUNITIES_THRESHOLD           = 2
CONNECTION_REQUESTS_THRESHOLD   = 3
REACTIONS_CONTRIBUTION_THRESHOLD = 5

# Key methods on Cp::Champion (via Tierable)
champion.engagement_tier          # :member | :champion | :community_leader
champion.compute_journey_stage    # Returns JOURNEY_STAGES key (does not persist)
champion.recompute_journey_stage! # Computes + saves journey_stage column
champion.has_contributions?       # true if board_reactions >= 5 or content_submissions > 0

# Tier predicates
champion.member_tier?
champion.champion_tier?
champion.community_leader_tier?

Cp::DashboardVisibility

app/services/cp/dashboard_visibility.rb β€” Maps a champion’s journey stage to which dashboard sections are visible and in what sidebar order.

service = Cp::DashboardVisibility.new(champion)
service.visible_sections   # Array of section symbols (e.g. [:profile_prompt, :communities, ...])
service.sidebar_order      # Ordered array for sidebar rendering

Cp::Milestone (Added February 2026)

app/models/cp/milestone.rb β€” Tracks achievement milestones per champion for celebration banners.

class Cp::Milestone < ApplicationRecord
  belongs_to :champion, class_name: "Cp::Champion", foreign_key: "cp_champion_id"
  # 8 milestone types: first_community, profile_complete, first_connection,
  #   first_post, champion_opt_in, one_year_anniversary, ten_connections, community_leader
  # Unique index on (cp_champion_id, milestone_type)
end

Cp::ActivityFeedService (Added February 2026)

app/services/cp/activity_feed_service.rb β€” Unified content feed merging discussions, news, and photo albums with scoring.

service = Cp::ActivityFeedService.new(champion)
service.feed_items   # Array of FeedItem structs (sorted by score, top 7)
# Scoring: recency (0-40) + engagement (0-40) + unseen bonus (+15) + community bonus (+10)

πŸ“ˆ Performance Notes


Last Updated: March 2026
Critical for: Engagement statistics, alumni filtering, report generation, Champion connections, Role Dashboard Card, Content Submissions, Journey Engine & Progressive Dashboard, Legal Compliance


Cp::PolicyVersion

app/models/cp/policy_version.rb β€” Central config for policy version constants. Bumping a constant triggers the re-consent flow for all existing champions.

# Constants
CURRENT_TERMS   = "2026-01-01"   # Bump to trigger re-consent for ToS
CURRENT_PRIVACY = "2026-01-01"   # Bump to trigger re-consent for Privacy Policy

# Class methods
Cp::PolicyVersion.consent_current?(champion)  # true if both policies at current version
Cp::PolicyVersion.stale_policies(champion)    # array of policy type strings needing re-consent

Cp::PolicyAcceptance

app/models/cp/policy_acceptance.rb β€” Immutable audit record for each policy acceptance event.

# Associations
belongs_to :champion, class_name: "Cp::Champion"

# Validations
validates :policy_type, inclusion: { in: %w[terms_of_service privacy_policy] }
validates :policy_version, presence: true
validates :accepted_at, presence: true

# Key method
Cp::PolicyAcceptance.record_for_champion!(champion, policy_type:, policy_version:, ip_address:)

# Indexes: [champion_id, policy_type], [policy_type, policy_version]

New Columns on cp_champions (Phase 16)

Column Type Purpose
terms_accepted_at datetime When ToS was last accepted
terms_version string Version string of accepted ToS
privacy_accepted_at datetime When Privacy Policy was last accepted
privacy_version string Version string of accepted Privacy Policy
age_confirmed_at datetime When 18+ age was attested at registration
education_privacy integer (enum) Education visibility: show_all (0), hide_year (1), hidden (2)
deleted_at datetime When account was soft-deleted
deletion_reason string Optional reason provided by champion
deletion_confirmed_at datetime When deletion was confirmed

Education privacy enum:

enum :education_privacy, {
  education_show_all: 0,
  education_hide_year: 1,
  education_hidden: 2
}, prefix: true, default: :education_show_all

# Helper methods
champion.show_education_year?    # true only for education_show_all
champion.show_education_details? # true unless education_hidden

Active/deleted scopes:

scope :active,  -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }

🀝 Alumni Opportunities (Phase 21)

Identity-agnostic on purpose (spec Β§7): both models live at app/models/ with no namespace, so a future belmontalum.com surface is an additive nullable column and a second controller on the same tables β€” not a rebuild. Neither model knows about Cp::Champion.

Opportunity

app/models/opportunity.rb β€” a staff-authored, DB-backed form definition. The slug, not the id, is the public URL segment (to_param returns it).

# Associations β€” joined by SLUG, not id
has_many :opportunity_responses,
         foreign_key: :opportunity_slug,
         primary_key: :slug,
         inverse_of: :opportunity,
         dependent: :restrict_with_error   # the destroy guard

# Scopes
Opportunity.active               # active: true
Opportunity.listed               # listed: true β€” ADVERTISED, not reachable (21.7)
Opportunity.unexpired            # no dates, OR its end date hasn't passed (21.8)
Opportunity.by_soonest           # starts_at ASC NULLS LAST, then title (21.8)
Opportunity.publicly_available   # active + unexpired, unless OPPORTUNITIES_ENABLED=false darks everything
Opportunity.publicly_readable    # active WITHOUT the expiry filter β€” the thank-you page only (21.8)

# Event details (21.8) β€” When / Where / The ask, plain text, not markdown
opportunity.event_details        # [[label, value]] in fixed order, BLANKS DROPPED
opportunity.event_details?       # false for every opportunity authored before 21.8
opportunity.skimmable_details    # event_details minus the ask β€” hub cards
opportunity.when_display         # one line: formatted date/time, or "Ongoing"
opportunity.date_display         # date line; "Ongoing" when undated β€” NEVER nil
opportunity.time_display         # clock line; nil for undated, all-day, or multi-day
opportunity.month_abbrev / day_of_month  # "SEP" / 30 for the hub date bar
opportunity.venue_line / address_line / map_url  # first line of location, then the rest
opportunity.card_details?        # has a real date or place β€” "Ongoing" does NOT count
opportunity.dated?               # starts_at present. FALSE MEANS ONGOING, not unknown
opportunity.closes_after         # end of the day (ends_at || starts_at) falls on
opportunity.expired? / publicly_available?

# Notification recipients (21.7) β€” per-opportunity, opt-in, blank sends nothing
opportunity.notify_email_list    # parsed, downcased, deduped
opportunity.notifies_by_email?
opportunity.notify_email_groups  # [[emails, staff_links?]] β€” split by portal-account ownership
Opportunity.all_portal_users?(emails)

# Alum's confirmation email (21.8) β€” opt-in, off by default
opportunity.send_receipt_email?

# Field readers (fields is an ordered jsonb array)
opportunity.active_fields        # active rows only
opportunity.question_fields      # active rows except the contact block
opportunity.contact_block_field
opportunity.field_for(key) / label_for(key)   # includes INACTIVE rows β€” staff view + CSV need retired labels

# Lifecycle guards
opportunity.slug_locked?         # activated_at present OR responses exist
opportunity.responses_exist?
opportunity.duplicate_for_editing  # unsaved inactive copy, fresh slug, regenerated keys
                                   # carries `listed`, `notify_emails`, `send_receipt_email`,
                                   # and the event COPY β€” but NOT starts_at/ends_at (21.8):
                                   # a copy is next year's occurrence, and an inherited
                                   # date would arrive already expired

active and listed are different questions (21.7). active controls whether the public URL resolves at all; listed controls whether we advertise it on the returning alum’s profile hub. A link-only opportunity (active: true, listed: false) is a fully working form that appears nowhere β€” for an Alumni Board ask, or one newsletter. listed is deliberately NOT part of publicly_available, or a shared link would 404.

Expiry is a third, independent question (21.8). A dated opportunity 404s once the end of the day ends_at || starts_at falls on has passed β€” end-of-day so a 10am event stays reachable while people are walking in. An undated opportunity never expires, which is what makes this a no-op for everything authored before 21.8.

publicly_readable exists solely so the thank-you page can outlive the expiry. Without it, anyone who submitted on the final day would lose their own receipt at midnight β€” and the confirmation email links to that page. It still enforces active, so switching a form off closes every page it has. Do not fold the expiry filter back into it.

Three integrity rules live in the model, not the UI β€” they are what make staff editing of a live form non-destructive:

Rule Enforced by Why
A field’s key is minted once from its label and frozen forever field_keys_are_immutable answers jsonb is keyed by it; renaming orphans every prior answer
Fields are soft-deleted (active: false), never removed same validation Removing a row strands its answers
The slug is immutable once activated or once responses exist slug_is_immutable_once_live It’s in every link already shared. Keys off activated_at, not active, so the deactivate β†’ fix β†’ reactivate rollback can’t quietly change a live URL

OpportunityResponse

app/models/opportunity_response.rb β€” one submission. The primary record for an anonymous submitter; no ChampionSignup is created on submit.

# Associations
belongs_to :opportunity, foreign_key: :opportunity_slug, primary_key: :slug,
           inverse_of: :opportunity_responses
belongs_to :champion_signup, optional: true   # THE ONLY link to a person
belongs_to :district, optional: true

# Scopes
OpportunityResponse.unreviewed / .reviewed        # read/unread for the badge β€” NOT a fulfillment state
OpportunityResponse.anonymous / .linked
OpportunityResponse.signup_verified               # link is PROOF
OpportunityResponse.signup_email_matched          # link is a GUESS from the address
OpportunityResponse.for_opportunity(slug)

# Key methods
response.display_location   # "Brentwood, TN" β€” city first, never the metro district (55dd1bf3)
response.answered_fields    # [[field, value]] in field order, INCLUDING retired questions

# Answer value handling (21.7) β€” `checkbox` fields store an ARRAY, so these class
# methods are the one place the scalar/array distinction is resolved. Four consumers
# need identical semantics: validation, the staff view, the CSV export, the email/PDF.
OpportunityResponse.answer_values(v)             # ticked values, blanks stripped
OpportunityResponse.format_answer(v, joiner:)    # display string (", " HTML/email; "; " CSV)
OpportunityResponse.answer_blank?(v)             # [] IS blank β€” `[].to_s` is "[]", which is not
response.conversion_token   # signed, 7-day β€” carries the response into the Phase 19 signup flow
response.to_signup_attributes
response.mark_reviewed! / mark_unreviewed!
response.signup_verified? / signup_email_match?   # how much to trust the link
response.link_verified_signup(signup)             # attach a signup they PROVED they own

signup_link_method β€” proof vs. a guess. champion_signup_id conflates two confidence levels, so the column records how the link was made: "verified" (they arrived with a profile token or an active session and submitted from their own hub) or "email_match" (the address they typed belongs to a signup on file β€” probably them, unconfirmed). The public thank-you page may greet and hub-link a verified submitter and must never do either for an email_match; the staff queue, its filters, and the CSV’s signup_match column all surface the difference so nobody acts on a guess as though it were a confirmation. Conversion through the signed token counts as verified and upgrades an existing match.

The email auto-link, and what it deliberately does not do. A before_validation on create calls ChampionSignup.find_active_by_email and stamps champion_signup_id when the address already belongs to an active signup. That is a server-side data-quality link only and must never surface: an opportunity form is public and anonymous, so if typing someone else’s email revealed or granted access to their record, the form would be a backdoor login. The thank-you page renders identical copy for matched and unmatched submitters (which also closes the email-enumeration leak), and the only route to a returning signup’s record stays the link emailed to the address on file.

Location derivation reuses ZipCode.lookup and is self-healing on the same condition as champion_signups: re-derive when the zip changes, and backfill a row that has a zip but no resolved city/district.

ChampionSignupEvent gained opportunity_viewed and opportunity_submitted (metadata: opportunity_slug), recorded only for known signups. Anonymous traffic records no event β€” it has no signup to hang one on, and the OpportunityResponse row is its record.