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 :engagement_activities |
EngagementActivity | Via buid |
| Alumni | has_many :champion_signups |
ChampionSignup | Via buid |
| ChampionSignup | belongs_to :alumni |
Alumni | Via buid |
| 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::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 |
| Major | belongs_to :college |
College | Via college_code |
Alumni β Degrees β Major β College
β β β β
buid major_code college_code
Alumni β ChampionSignups (prospect/champion status)
β β
buid status enum (1-5)
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)
has_many :champion_signups, foreign_key: :buid, primary_key: :buid
enum prospect_status: { not_prospect: 0, prospect: 1 }
# 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) }
# Data integrity notes:
# - lifestage_interest field uses comma-separated keywords format
# - Valid values: 'almost-alumni', 'young-alumni', 'tower-society', 'families', 'other'
# - 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 :cp_champion, class_name: "Cp::Champion", optional: true
end
class Degree < ApplicationRecord
belongs_to :major, foreign_key: :major_code, primary_key: :major_code
# β οΈ NO direct college association - must go through major
end
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
# 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 })
# Filter alumni by champion status (Added August 2025)
Alumni.joins(:champion_signups)
.where(champion_signups: { status: 5 }) # Completed champions
# Get manual prospects only
Alumni.where(prospect_status: 1)
.where.not(id: Alumni.joins(:champion_signups).select(:id))
# Complex champion/prospect filtering
Alumni.filter_by_alumni_status('champions_and_prospects') # Custom scope
# 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: 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
Alumni.joins(:champion_signups).where(champion_signups: { status: 1..4 }).count
buid (string)buid links to Alumni, major_code links to Majorcollege_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) }