alumni_lookup

Model Relationships & ActiveRecord Associations

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

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

🎯 Quick Reference

Model Association Target Key Details
EngagementActivity belongs_to :alumni Alumni ⚠️ NOT :alumnus
EngagementBatchLog belongs_to :api_key ApiKey Optional, tracks which key made the sync
Alumni has_many :degrees Degree Via buid
Alumni has_many :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

πŸ”— Association Chain

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

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

πŸ“‹ Model Definitions

EngagementActivity

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

Alumni

class Alumni < ApplicationRecord
  # Existing associations
  has_many :degrees, primary_key: :buid, foreign_key: :buid
  has_many :engagement_activities, foreign_key: :buid, primary_key: :buid
  
  # Champion/Prospect system (Added August 2025)
  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

ChampionSignup (Added August 2025)

class ChampionSignup < ApplicationRecord
  belongs_to :alumni, primary_key: :buid, foreign_key: :buid, optional: true
  
  enum status: {
    started: 1,
    completed_questions: 2,
    selected_role: 3,
    interests: 4,
    zip_code: 5  # Complete champion designation
  }
  
  scope :completed, -> { where(status: 5) }
  scope :in_progress, -> { where(status: 1..4) }
  
  # 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

Cp::ActivityEvent (Added December 2025)

class Cp::ActivityEvent < ApplicationRecord
  belongs_to :champion, class_name: "Cp::Champion", foreign_key: :cp_champion_id
end
class CrmDataChange < ApplicationRecord
  belongs_to :cp_champion, class_name: "Cp::Champion", optional: true
end

Degree

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

Major

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

College

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

πŸ”„ Common Join Patterns

βœ… Correct Joins

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

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

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

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

# 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

❌ Common Mistakes

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

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

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

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

🚨 Error Messages & Solutions

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

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

Column ambiguity errors in complex joins (Added August 2025)

πŸ§ͺ Testing Associations in Rails Console

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

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

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

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

# Test prospect filtering
Alumni.where(prospect_status: 1).count
Alumni.joins(:champion_signups).where(champion_signups: { status: 1..4 }).count

πŸ’Ύ Database Keys


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

Cp::RoleIdea

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

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

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

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

Cp::RoleIdeaPack

Daily pack of role ideas generated for each Champion.

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

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

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

Cp::RoleIdeaPackItem

Join model linking packs to individual ideas with position ordering.

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

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

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

Cp::Connection

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

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

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

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

Cp::ConnectionRequest

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

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

# Constants
CONNECTION_TYPES = %w[say_hi career_advice networking]

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

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

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

Champion Connection Associations

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

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

MessageThread Connection Additions

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

ContentSubmissionThread (Phase 14)

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

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

ContentSubmissionMessage (Phase 14)

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

🎯 Journey Engine & Tier Detection (Phase 13)

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

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

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

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

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

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

Cp::DashboardVisibility

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

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

Cp::Milestone (Added February 2026)

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

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

Cp::ActivityFeedService (Added February 2026)

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

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

πŸ“ˆ Performance Notes


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


Cp::PolicyVersion

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

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

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

Cp::PolicyAcceptance

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

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

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

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

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

New Columns on cp_champions (Phase 16)

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

Education privacy enum:

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

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

Active/deleted scopes:

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