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 :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 |
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)
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.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.
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 :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
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
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.
class Degree < ApplicationRecord
belongs_to :major, foreign_key: :major_code, primary_key: :major_code
# β οΈ NO direct college association - must go through major
end
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).
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
class College < ApplicationRecord
has_many :majors, foreign_key: :college_code, primary_key: :college_code
has_many :degrees, through: :majors
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.
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 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
# 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
Can't join 'EngagementActivity' to association named 'alumnus':alumnus instead of :alumni:alumnus to :alumni in joinsCan't join 'Degree' to association named 'college'{ degrees: { major: :college } }status, id)alumni.prospect_status = 1 or champion_signups.status = 5# 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
buid (string)buid links to Alumni, major_code links to Majorbuid 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)
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.