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.
| 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 |
The legacy
degreestable andDegreemodel were dropped in September 2026. Degrees come fromeducations; read them throughAlumni::EducationProfile.
Alumni β Educations β EducationAreasOfStudy
β β β
buid source_education_id person_area_of_study_id
Alumni β ChampionSignups (prospect/champion status)
β β
buid status enum (1-5)
Alumni::EducationProfileDerived 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.)
class EngagementActivity < ApplicationRecord
belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true
# β οΈ CRITICAL: Association name is :alumni (NOT :alumnus)
end
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
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
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
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.
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.
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
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 viaeducation_areas_of_study.major_codejoined througheducations(no longer the frozendegreestable).
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.
class Major < ApplicationRecord
belongs_to :college, foreign_key: :college_code, primary_key: :college_code
end
class College < ApplicationRecord
has_many :majors, foreign_key: :college_code, primary_key: :college_code
end
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:
ChampionSignup does not validate industryvalidates :industry, inclusion: { in: Industry::ALL }, allow_blank: true.
Legacy values were audited first; none were off-list.cp_communities.industry is still unvalidated β the third table holding the
taxonomy. One-line held write, filed as BACKLOG 9.5.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 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.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.
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:
cp_champions.first_name maps to
first_name or pref_name depending on whether legal_first_name is
present. A flat hash cannot express that, so legal_first_name is an alias and
never a MAP entry.pref_first_name is a mirror, not a preferred name. Cp::Championβs
before_save :normalize_names overwrites it with first_name every save, so
the column cannot hold an independent value. Recorded as
IdentityField::PREF_NAME_IS_MIRRORED; BACKLOG 9.3.cp_champions.state is limit: 2 and alumni.zip is
limit: 10 while their counterparts are unbounded β a value valid in one table
can be untruncatable into another. See IdentityField::WIDTH_CONSTRAINTS.Deciding which record is true when the three disagree is Phase 26. This is its prerequisite, not its replacement.
# 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
# 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
Can't join 'EngagementActivity' to association named 'alumnus':alumnus instead of :alumni:alumnus to :alumni in joinsstatus, id)alumni.prospect_status = 1 or champion_signups.status = 5# 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
buid (string). Two other ids live on the row and are not keys to anything here: contact_id (BQID, C-000000000, unique) and salesforce_contact_id (15-char, case-sensitive, NON-unique β the giving importβs join key, Phase 29)buid links to Alumni (alumni.buid), plus optional granting_school_code / current_school_code links to Collegeeducation_id links to Educations; optional current_institutional_unit_code links to Collegecollege_code links to Collegebuid links to Alumnibuid links to Alumni (Added August 2025)board_post_id (NOT NULL) links to Cp::BoardPost β use dependent: :destroy (Added January 2026)champion_a_id, champion_b_id link to Cp::Champion; message_thread_id links to Cp::MessageThread (Added February 2026)requestor_id, requestee_id link to Cp::Champion (Added February 2026)cp_champion_id links to Cp::Champion (Added February 2026)cp_role_idea_pack_id links to Cp::RoleIdeaPack, cp_role_idea_id links to Cp::RoleIdea (Added February 2026)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:"
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+
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 }
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)
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)
# 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?
# 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"
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
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
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?
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
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
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)
Education::AggregateScope (current school, else granting school)buid, major_code, college_codeincludes() for N+1 query prevention when loading associated recordsLast Updated: March 2026
Critical for: Engagement statistics, alumni filtering, report generation, Champion connections, Role Dashboard Card, Content Submissions, Journey Engine & Progressive Dashboard, Legal Compliance
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
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]
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) }
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.
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 |
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.