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 :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)
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

The legacy degrees table and Degree model were dropped in September 2026. Degrees come from educations; read them through Alumni::EducationProfile.

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.educations directly.

profile = alumni.education_profile      # memoized; auto-infers privacy from champion
profile.all_entries                     # [Entry] β€” awarded educations, most recent first
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

Entries come from awarded Education rows plus their EducationAreaOfStudy rows (the primary major supplies major_name and major_code). 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 or :none β€” used by both API endpoints (Api::AlumniController and Api::V1::AlumniSearchController) to emit a top-level _source advisory field. (:degree_fallback ended with the degrees table.)

πŸ“‹ 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 :educations, 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

  # salesforce_contact_id (Added September 2026, Phase 29) β€” a THIRD id, and
  # none of the other two:
  #
  #   buid                   B00123456          Belmont University ID
  #   contact_id             C-000000001        BruinQuest ID (BQID), unique
  #   salesforce_contact_id  003Uq00000QBvQK    Salesforce contact id, 15 chars
  #
  # `primary_contact_id` is none of these either β€” it is the assigned staff user.
  #
  # Three things about this column that look like bugs and are not:
  #   * CASE-SENSITIVE. 003Uq00000QBvQK and 003Uq00000QbvQK are two different
  #     contacts, observed in real data. Never .upcase it anywhere.
  #   * 15 characters, not 18. The 18-char form is the same value plus a
  #     case-recovery checksum; an 18-char value is truncated on write.
  #   * NON-unique index, unlike contact_id. Two alumni rows sharing one
  #     Salesforce id means duplicate alumni records (routine, resolved by hand)
  #     or a CRM mis-map. A unique index would fail the CONTACT import mid-batch
  #     over it; the giving import preview catches it instead.
  #
  # Written by Csv::AffinaquestContactImporter (fill-when-blank; a mismatch is
  # logged as a CrmDataChange, never overwritten). Read by Csv::GivingImporter,
  # which joins the FY gift report on it.
  
  # 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
  # ⚠️ Column is `export_batch_id`, NOT the inferred `crm_data_export_batch_id`.
  belongs_to :export_batch, class_name: "CrmDataExportBatch", optional: true
end

CrmDataExportBatch (Aug 2026 β€” rewritten)

class CrmDataExportBatch < ApplicationRecord
  # ⚠️ `foreign_key:` is REQUIRED. Rails infers `crm_data_export_batch_id`;
  # the actual column is `export_batch_id`. That mismatch is why this
  # association raised PG::UndefinedColumn for eight months β€” undetected,
  # because nothing ever called it.
  has_many :crm_data_changes, class_name: "CrmDataChange",
           foreign_key: :export_batch_id, inverse_of: :export_batch,
           dependent: :nullify
  belongs_to :exported_by, class_name: "User", optional: true
  belongs_to :undone_by, class_name: "User", optional: true
end

Until Aug 2026 this model declared eight columns of which seven did not exist (export_type, status, record_count, file_name, started_at, completed_at, exported_by). It was unreachable dead code, so it never raised. test/models/crm_data_export_batch_test.rb now pins the column list against the real table. See docs/development/UNIFIED_CRM_SYNC.md.

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 β€” removed (September 2026)

The frozen legacy degrees table (buid, degree_code, degree_date, major_code) was dropped; awarded degrees come from Education. 572 production alumni whose degrees existed only there (mostly 1950s–70s graduates the CRM export does not include) now have no degree record, by decision.

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).

Import batches β€” ImportBatch concern (Phase 30)

AffinaquestImportBatch (Contacts), EducationImportBatch (Educations and Areas of Study, split by kind) and EmploymentImportBatch each keep their own table, and all three include ImportBatch:

belongs_to :imported_by, class_name: "User", optional: true   # from the concern
has_many   :conflicts, class_name: "AffinaquestImportConflict",
           foreign_key: :import_batch_id, dependent: :nullify  # AffinaquestImportBatch only

Which table and kind a type uses is registered in Imports::Type, not inferred. EducationImportBatch ids can therefore be valid under the wrong type, and the imports controller redirects them. Contacts’ field-level audit rows (CrmDataChange) are tied to their batch by text in notes ("Imported from Affinaquest (batch #12)"), not by a foreign key. Read them through AffinaquestImportBatch#field_changes, which anchors the match on ) so batch #12 does not also match #123.

Major

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

College

class College < ApplicationRecord
  has_many :majors, foreign_key: :college_code, primary_key: :college_code
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.

Country (Added October 2026, Phase 31) β€” not an ActiveRecord model

Country.find("CA")      # => #<data Country::Record code="CA", name="Canada", continent="north_america">
Country.find("CA").district_code   # => "intl-north-america"

There is no countries table. The list is config/countries.yml. It stores as an ISO alpha-2 string in champion_signups.country and opportunity_responses.country, where NULL means United States. Both models include the shared InternationalLocation concern, which owns the location rules (see docs/features/CHAMPION_SIGNUP_SYSTEM.md).

No FK from a country to its district, deliberately. The continent district is found by District.find_by(code: Country#district_code) and stored in the existing district_id. Staff may rename a district, so the code, not the name, is the stable join. zip_code stays U.S.-only on both tables; an international postal code lives in postal_code so no ZipCode.lookup can resolve it against a U.S. ZIP.

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 alumni by college or graduation year (awarded educations)
Alumni.filter_by_college("CB")
Alumni.where(buid: Education::AggregateScope.buid_subquery(fiscal_year: 2024))

# 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: Reading degree data straight off educations for display
alum.educations.first.degree_code  # Use alum.education_profile (privacy, awarded-only, majors)

# 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, :educations)
      .where(status: 5)  # Should be champion_signups.status = 5

🚨 Error Messages & Solutions

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

Column ambiguity errors in complex joins (Added August 2025)

πŸ§ͺ Testing Associations in Rails Console

# Verify the association chain works
alumni = Alumni.first
alumni.education_profile.recent&.college_name

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

# Test college filtering
Alumni.filter_by_college("CB").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.