Status: Complete (18.1-18.9 shipped; released in v1.0.65). Degree-model deprecation + dropping the legacy degrees table are intentionally held for a separate post-import cleanup tag once Education coverage ≥ 99% — colleges/majors are retained as active reference tables.
Priority: High
Estimated Sub-Phases: 9
educations and education_areas_of_study schema (additive, no read-path cutover yet)Education and EducationAreaOfStudy models with associations and validationseducations.buid -> alumni.buideducations (buid, source_education_id)education_areas_of_study.person_area_of_study_iddegree_level buckets: undergraduate, masters, doctorate, unknownarea_of_study_name_normalized (lowercased + whitespace-collapsed)EducationTestEducationAreaOfStudyTestdb/migrate/20260422093100_create_educations.rbdb/migrate/20260422093200_create_education_areas_of_study.rbdb/migrate/20260422100500_remap_education_degree_levels.rbapp/models/education.rbapp/models/education_area_of_study.rbtest/fixtures/educations.ymltest/fixtures/education_areas_of_study.ymltest/models/education_test.rbtest/models/education_area_of_study_test.rbdocs/planning/champion-portal/qa/PHASE_18_LAUNCH_GUIDE.mddocs/planning/champion-portal/phases/phase-18/samples/README.mdbin/test => 4274 runs, 11180 assertions, 0 failures, 0 errors, 3 skipsMigrate from the current academic structure:
Alumni -> Degree -> Major -> CollegeTo a new, person-centered model:
Alumni -> Education -> EducationAreaOfStudyThis phase introduces a richer representation of educational history while preserving existing downstream integrations during rollout.
Education (0..n per Alumni)degree_level (optional; may be implied by degree_code)degree_codedepartment_name (free text for post-2000 source data)granting_school_code and/or granting_school_name (historical source-of-award school)current_school_code and/or current_school_name (where the program lives now)date_issuedEducationAreaOfStudy (0..n per Education)person_area_of_study_id (person-unique identifier from source, if available)area_of_study_name (raw source text; normalization deferred)concentration_level (major, minor, concentration)current_institutional_unit_code and/or current_institutional_unit_nameThe current Degree -> Major -> College model assumes a single major and current-college context attached to each degree row. It is insufficient for:
majors reference dataThe following systems are directly impacted because they query degrees, majors, and/or colleges:
Settings::AlumniController degree and banner preview/commit flowsCsv::AlumniImporterCsv::BannerImporterCsv::CurrentStudentImporter (degree/no-degree assumptions, intended_degree_code, expected_graduation_year)lib/tasks/current_students.rake (groups by colleges.college_desc)lib/tasks/db_snapshot.rake (core tables list includes degrees/majors/colleges)lib/tasks/verify.rake (degree count verification)Alumni model scopes and helpers (filter_by_college, filter_by_major, recent_degree, graduation_years, with_degrees, without_degrees, current_student?)Degree model scopes (undergraduate, graduate, filter_by_college, by_year, by_fiscal_year, fiscal year helpers)AlumniController filters and show display pathsapp/views/layouts/_navbar.html.erb “Degree Stats” linkapp/controllers/concerns/timeout_protection.rb flash copy references “college or year”app/controllers/concerns/fiscal_year_helpers.rb defaults to degree_date columnStatisticsController (Degree Stats pages + CSV download)EngagementStatsController (UG/GR derivation logic) + EngagementStats::*ServiceAlumniFilterService college/year filteringEngagementStats::DemographicsService college/major breakdownsCp::Community model: community_type: :college/:major, college_code, major_code, qualification methods (champion.alumni.degrees.any? { |d| d.major&.college_code == ... }), find_or_create_college_community, find_or_create_major_community, college / major_record accessorsCp::CommunityCreationJob (community_types: %w[college major])Cp::CareerConnectService (joins alumni: { degrees: :major }, uses majors.major_desc and majors.college_code, candidate_degree_clusters)Cp::ChampionRecommendation (eager loads alumni: { degrees: :major } for “Same college/Same major” badges)Cp::Champion fields: anticipated_college_code, affiliated_college_code, anticipated_program, anticipated_graduation_year, affiliated_department_or_college_name (resolves via College.find_by)Cp::Champion Education Privacy setting (Phase 16.2) — controls visibility of degree/major/year on profileapp/views/champions/champions/show.html.erb (line 253: degree display; line 170+: anticipated college dropdown)app/views/champions/verifications/show.html.erb and _search_results.html.erb (degree rendering, anticipated/affiliated college display)app/views/champions/communities/new.html.erb + community_type_form_controller.js (College/Major community creation form)Champions::CommunitiesController#autocomplete_majors endpointChampions::VerificationsController (triggers Cp::CommunityCreationJob with college/major types)app/helpers/cp/communities_helper.rb and app/helpers/champions/communities_helper.rb (render college/major community names + descriptions)app/helpers/cp/home_helper.rb (grad year display from champion.alumni.degrees)app/services/legacy_verification_service.rb (recent_degree.major_desc, recent_degree.degree_date)app/services/cp/data_export_service.rb (college_last_name field)Csv::AlumniExporter (must include both buid and contact_id)Csv::EventRsvpConverterrecent_degree.major_desc, grad year)Api::AlumniController (serializes recent_degree with major_desc, college_name, degree_code, degree_date)Api::V1::AlumniSearchController#serialize_alumni (ug_college, ug_program, ug_degree, ug_graduation_year, gr_*, pref_college fields used by downstream apps)Api::MajorsController (#index, #search, returns major_code, major_desc, college_code, college_name_short)Api::ActivityDescriptionsController (filter_by_college)College and Major (/settings/colleges, /settings/majors)config/career_clusters.yml (entire mapping is college_codes + major_overrides-based)config/welcome_packs.yml (/ interpolation tokens; college: and major: community-type sections)config/faq.yml (user-facing copy referencing college filters and “Same college / Same major” recommendation badges)lib/tasks/community_bootstrap.rake (creates college/major communities from Degree records)lib/tasks/belmont_stories.rake (filters scraping by college_code)Cp::NewsPost colleges association + college_tag string fieldapp/views/champions/news_posts/show.html.erb “Colleges” cardapp/views/champions/content_submissions/* (renders college_tag)Cp::SeededQuestion target_audience values include college / majorapp/views/champions/seeded_questions/_form.html.erb interpolation help ({college_name})/stats/degrees.csvsettings/alumni/upload_degrees, import_degrees_preview, import_degrees_commit, import_degreessettings/colleges, settings/majors resource routesapi/majors (#index, #search)champions/communities/autocomplete_majorsPhase 18 is designed so downstream consumers can continue reading existing API fields while internals migrate.
ug_*, gr_*, pref_college) with per-record fallback to legacy degrees when educations are missingGoal: Finalize non-ambiguous mapping and data ownership before schema work.
undergrad / masters / doctorate) from degree_codegranting_school vs current_schoolarea_of_study_name normalization strategy (text-first vs normalized table)| # | Topic | Decision | Rationale |
|---|---|---|---|
| 1.1 | Source feed shape | Two CSVs: awarded-degrees + areas-of-study, joined via Education: Education natural key from source |
Matches actual exports the data team will provide |
| 1.2 | Legacy degrees/majors/colleges fate |
Plan toward decommission. Phase 18 keeps them readable for transition, Phase 19+ retires after dependents migrate | Data team is maintaining the new model going forward |
| 1.3 | Backfill strategy | No backfill. New exports include all records. Missing/mismatched data flows into a gap report so the CRM team can fix source | Source-of-truth is the CRM; gaps are signals, not data we should fabricate |
| 1.4 | Granting school setup | Pre-seed all unique granting-school codes/names from source list before 18.3 import runs | Avoids null school-code mappings for known schools and supports icons/short names immediately |
| 2.1 | degree_level storage |
Hybrid: physical column auto-derived from degree_code on save |
Indexable for stats joins; never drifts because regenerated from canonical field |
| 2.2 | granting_school vs current_school |
Both stored as *_name (free text from source, never lossy) + *_code (resolved via lookup, nullable when no match) on the same educations row |
Lets us preserve historical attribution AND join to the colleges table when known |
| 2.3 | department_name storage |
Free text on Education for Phase 18. No departments reference table yet. Reserve normalization for a follow-up phase |
Source provides free text; existing majors.dept_desc usage is small (4 sites) and routes through compatibility presenter |
| 2.4 | concentration_level enum |
major, minor, concentration (lowercased on ingest) |
Matches source exactly |
| 3.1 | API V1 contract | (c) additive: keep all current fields stable; add optional educations: [...] block behind ?include=educations opt-in |
Zero-break for downstream apps; new richness is opt-in |
| 3.2 | UG/GR derivation | Most recent Education by date_issued per level, focused on concentration_level: "major" areas of study |
Simple, deterministic, matches user intent |
| 3.3 | Cp::Community college qualification |
Match on current_school_code only |
Aligns with how the CRM frames the “where it lives now” school |
| 3.4 | “Same major” recommendation | Match on normalized area_of_study_name where concentration_level: "major" |
Future-proof against legacy major_code rot |
| 3.5 | Compatibility fallback | If an alum has zero educations, presenter falls back to legacy degrees for all legacy fields during migration window |
Prevents empty API/profile output when source coverage is temporarily incomplete |
| 3.6 | Coverage tracking | Add migration coverage stats and make full cutover contingent on threshold | Provides objective readiness signal and protects downstream behavior |
| 4.1 | Current-student fields | Stay on Alumni unchanged. No Education row created until graduation | Educations represent awarded degrees, not in-progress study |
| 4.2 | Career Clusters / Welcome Packs YAML | Phase 18: unchanged. Compatibility presenter exposes legacy keys (primary_major_desc, primary_college_code) so YAMLs keep working. Phase 19 backlog: rethink YAMLs against new vocabulary |
Keeps Phase 18 finite; data won’t structurally change again so YAML refactor is safe to defer |
| 4.3 | majors/colleges admin pages |
Hide/remove from Settings nav after Phase 18.7 | They become legacy reference data; data team owns updates via the new feeds |
| 4.4 | Phase 18 scope | All Impact Inventory items in scope. Single-shot transition. No carve-outs into Phase 19 except YAML refactor (4.2) | User wants one decisive transition |
Source: awarded-degrees.csv → educations
| Source column | Target column | Type | Notes |
|---|---|---|---|
Contact: BUID |
educations.buid |
string, NOT NULL, indexed | FK-style to alumni.buid |
Contact: BruinQuest - Contact ID |
(matching only, not stored) | — | Used for alumni resolution / gap report |
Education: Education |
educations.source_education_id |
string, NOT NULL | Natural key from CRM. Anchors idempotency + areas-of-study join |
Degree Code |
educations.degree_code |
string, NOT NULL | e.g., BBA, BS, MA |
| (derived) | educations.degree_level |
string, indexed | undergraduate / masters / doctorate / unknown, auto-set from degree_code on save |
Granting School |
educations.granting_school_name |
string, NOT NULL | Preserved verbatim |
| (resolved) | educations.granting_school_code |
string, nullable, indexed | Lookup against colleges.college_name + alias map; nullable on miss |
Current School |
educations.current_school_name |
string, NOT NULL | Preserved verbatim |
| (resolved) | educations.current_school_code |
string, nullable, indexed | Lookup as above |
Department |
educations.department_name |
string, nullable | Free text; populates legacy ug_program/gr_program API field |
Date Issued |
educations.date_issued |
date, indexed | |
Institutional Unit |
(ignored in Phase 18) | — | Frequently blank; redundant with Current School in samples. Revisit if data team confirms semantics |
Institutional Units |
(ignored in Phase 18) | — | Duplicate of Institutional Unit |
Uniqueness: (buid, source_education_id) unique index → idempotent reimports.
Source: areas-of-study.csv → education_areas_of_study
Filter: only ingest rows where Degree Includes this Concentration? = 1.
| Source column | Target column | Type | Notes |
|---|---|---|---|
Education: Education |
(join key) | — | Resolves to educations.id via source_education_id |
Area of Study: Area of Study Name (col 1, e.g. AS-196770) |
education_areas_of_study.person_area_of_study_id |
string, unique | Natural key for idempotency |
Area of Study: Area of Study Name (col 2, e.g. Business Administration) |
education_areas_of_study.area_of_study_name |
string, NOT NULL | Free text, preserved verbatim |
| (derived) | education_areas_of_study.area_of_study_name_normalized |
string, indexed | Lowercased + trimmed; powers “same major” matching and future grouping |
Concentration Level |
education_areas_of_study.concentration_level |
string enum | major / minor / concentration (lowercased) |
Current Institutional Unit |
education_areas_of_study.current_institutional_unit_name |
string, nullable | Free text |
| (resolved) | education_areas_of_study.current_institutional_unit_code |
string, nullable, indexed | Lookup against colleges.college_name |
A short alias map handles known mismatches between source naming and colleges.college_name. Known examples from samples:
| Source name | Maps to colleges.college_code |
|---|---|
Jack C. Massey College of Business |
CB |
College of Business (granting, legacy) |
CB |
Mike Curb College of Entertainment and Music Business |
CE |
College of Entrmnt/Musc Busnes (legacy) |
CE |
College of Sciences and Mathematics |
CM |
College of Sciences & Math (legacy) |
CM |
College of Music and Performing Arts |
MP |
College of Vis/Performing Arts (legacy) |
VP |
College of Pharmacy & Health Sciences |
PH |
University College |
UC |
In addition to alias mapping, pre-seed the school reference list from source before migration imports. Source codes provided:
OM, WC, CA, CB, ED, CE, CH, CL, CS, MC, MP, CN, CP, PH, CM, CT, CV, CI, CR, HO, HU, MU, NU, RE, SC, SM, 00, UC, WA.
This pre-seed step should include canonical long name + short name + icon metadata where available so profile and stats surfaces are ready at cutover.
The importer flags these in a gap report (no row inserted into colleges; Education row inserted with *_name populated and *_code NULL). Staff/data team triage from the gap report.
These keys in Api::V1::AlumniSearchController#serialize_alumni MUST remain present and semantically equivalent post-Phase 18. Internal data source migrates to educations via a presenter; downstream consumers see no change.
Frozen keys: buid, contact_id, first_name, last_name, email, phone_number, pref_college, ug_college, ug_program, ug_degree, ug_graduation_year, ug_college_desc, gr_college, gr_program, gr_degree, gr_graduation_year, gr_college_desc, current_student, current_school, current_school_desc, current_program, intended_degree, expected_graduation_year, student_status, district, district_code, is_faculty, is_staff, category, company, position.
Derivation rules (presenter):
ug_education = most recent educations row where degree_level = "undergraduate"gr_education = most recent where degree_level IN ("masters", "doctorate")ug_program / gr_program = department_name of that education (preserves current dept_desc semantics)ug_college / gr_college = current_school_code of that educationug_college_desc / gr_college_desc = current_school_name (or resolved colleges.college_name when code present)pref_college = ug_college || gr_college || alum.current_school_codeeducations are empty for an alum during migration, derive all legacy fields using current legacy degrees logic as a temporary fallbackTrack migration readiness continuously during 18.3-18.5:
education_coverage_pct = alumni with degrees OR current-student indicators who have at least one educations row / alumni expected in awarded exportlegacy_fallback_pct = API/profile reads where presenter had to use legacy degrees fallback / total readsunmapped_school_name_count = distinct granting_school_name/current_school_name values with null resolved codeCutover expectation:
education_coverage_pct meets agreed threshold (recommended: >= 99%) and legacy_fallback_pct trends toward 0.Additive (opt-in ?include=educations): Full educations: [...] array with nested areas_of_study per education.
Sample CSVs reviewed during 18.1 are now committed in docs/planning/champion-portal/phases/phase-18/samples/:
awarded-degrees.csvareas-of-study.csvThese are the baseline input files for 18.3 import preview/commit tests and should remain stable unless the source extract shape changes.
Goal: Add new education schema with safe coexistence alongside legacy tables.
educations table keyed to alumni.buid (with DB-level FK)education_areas_of_study table keyed to educationsEducation (auto-derives degree_level; optional granting_college / current_college associations)EducationAreaOfStudy (auto-derives area_of_study_name_normalized; downcases concentration_level)Alumni (has_many :educations, has_many :education_areas_of_study, through: :educations)buid, date_issued, degree_level, school code fields)(buid, source_education_id) unique on educations; globally unique person_area_of_study_id)20260422093100_create_educations, 20260422093200_create_education_areas_of_study) with no impact on existing read paths.Education#derive_degree_level collapses degree_code into the four buckets defined in 18.1 (undergraduate / masters / doctorate / unknown) on every save, so the column never drifts from the canonical field.EducationAreaOfStudy.normalize_name is the single source for the lowercased + whitespace-collapsed form used by future “same major” matching.educations.buid -> alumni.buid is enforced (proven by a test that asserts ActiveRecord::InvalidForeignKey on an unknown buid).set_fixture_class education_areas_of_study: EducationAreaOfStudy added to test/test_helper.rb because Rails’ default camelization (EducationAreasOfStudy) does not match the model class name.docs/planning/champion-portal/qa/PHASE_18_LAUNCH_GUIDE.md) and sample data docs/files under phases/phase-18/samples/ created as part of the phase-start scaffolding.Status: Complete
Goal: Stand up a new-CSV import pipeline (awarded-degrees + areas-of-study) that writes to educations and education_areas_of_study with idempotent upserts and a downloadable gap report. Imports run as background jobs with a polled status page so production uploads cannot time out on Heroku.
awarded-degrees.csv import preview/commit -> educationsareas-of-study.csv import preview/commit -> education_areas_of_study(buid, source_education_id) and person_area_of_study_id)concentration_level = "unknown" and surfaced in gap report)EducationImportScanJob + EducationImportApplyJob) with persistent EducationImportBatch records and Zlib-compressed CSV/manifest stored in DB binary columns (mirrors CurrentStudentImportBatch)SCHOOL_ALIASES + colleges.college_name resolution)buid, contact_id, … for Advancement Services lookuprunning before enqueueing the apply job so the redirect lands on a page that polls (no more “Batch is not ready for commit (status: completed)” alert from a double click)db/migrate/20260423090000_seed_granting_school_colleges.rbdb/migrate/20260423110000_create_education_import_batches.rbapp/models/education_import_batch.rbapp/services/csv/education_importer.rbapp/services/csv/education_area_of_study_importer.rbapp/services/education_import_manifest_store.rbapp/jobs/education_import_scan_job.rbapp/jobs/education_import_apply_job.rbapp/views/settings/alumni/upload_educations.html.erbapp/views/settings/alumni/upload_areas_of_study.html.erbapp/views/settings/alumni/show_education_import_batch.html.erbapp/views/settings/alumni/_education_import_recent_batches.html.erbapp/views/settings/alumni/_education_import_preview_table.html.erbapp/views/settings/alumni/_areas_of_study_import_preview_table.html.erbtest/services/csv/education_importer_test.rbtest/services/csv/education_area_of_study_importer_test.rbtest/services/education_import_manifest_store_test.rbtest/models/education_import_batch_test.rbtest/jobs/education_import_scan_job_test.rbtest/jobs/education_import_apply_job_test.rbtest/controllers/settings/alumni_controller_educations_test.rbapp/models/education_area_of_study.rb (added "unknown" to CONCENTRATION_LEVELS for import-with-fallback)config/routes.rb (4 form routes + 4 batch-resource routes under settings/alumni)app/controllers/settings/alumni_controller.rb (7 async actions + enqueue_education_import_scan helper; replaces all session-based stash/read helpers from the initial sync ship)app/views/settings/_sidebar.html.erb (Educations/Areas of Study moved under “Data Imports”)app/views/settings/alumni/upload_degrees.html.erb (added nav buttons to new CRM importers)awarded-degrees.csv (or areas-of-study.csv) via the form on settings/alumni/upload_educations.EducationImportBatch (Zlib-compressed CSV in csv_content) and enqueues EducationImportScanJob.settings/alumni/education_import_batches/:id. The status page polls every 5 seconds.preview, stores the manifest via EducationImportManifestStore (Zlib-compressed JSON in manifest_data), builds the gap CSV (gap_csv_data), and transitions the batch to scanned.EducationImportApplyJob.commit, transitions the batch to completed, and clears csv_content + manifest_data (gap CSV is preserved for the audit trail).bin/test => 4331 runs, 11389 assertions, 0 failures, 0 errors, 3 skipsGoal: Centralize education derivation so old and new data can be served consistently.
Status: ✅ Complete
Alumni::EducationProfile) that derives:
degree.major.major_descalumni.degrees formatting calls in core profile/search pathsAlumni#recent_degree/helper methods or provide equivalent wrappersAlumni::EducationProfile (app/services/alumni/education_profile.rb) — single source of truth for derived education data on an Alumni record. Education-first with Degree fallback per alumni. Value objects: Privacy, Entry, CurrentSchool.
Entry carries normalized fields (degree_code, degree_level, major_name, major_code, department_name, college_code, college_name, college_name_short, college_icon, date_issued, source, hide_year) plus legacy aliases (major_desc, degree_date) and a custom as_json that preserves the V1 API contract.degree_level is derived at read time via Education.level_for(degree_code), not trusted from the stored column — resilient against fixtures and stale rows that skip the before_validation callback.alumni.champion.education_privacy unless viewer: matches the displayed champion (self-view never redacts). hidden? empties rollups and replaces display_summary with “Belmont University graduate”; hide_year? returns nil from Entry#year.undergraduate, graduate, all_entries, recent, current_school, college_codes, major_codes, grad_years, display_summary, to_export_hash.Alumni#recent_degree and Alumni#graduation_years now delegate to the presenter (memoized via Alumni#education_profile).AlumniHelper#generate_checkin_data rewritten to use to_export_hash — single export contract used by the event check-in copy button.AlumniHelper#degree_border_color updated to accept either Degree or Entry (classifies by degree_code[0]).alumni/show.html.erb degree card, alumni/search.html.erb (all three mobile/desktop blocks).cp/directory/_champion_card.html.erb, cp/directory/_recommendation_card.html.erb, cp/directory/show.html.erb, cp/profile/show.html.erb. Undergrad/grad split now driven by derived degree_level instead of hard-coded code lists.Cp::CommunityMatchingService#check_degree_suggestions, Cp::AlumniLikeMeService#extract_profile, Cp::CareerConnectService#champion_clusters & #candidate_degree_clusters.Api::AlumniController) continues using recent_degree.as_json with the contract-preserving payload from Entry#as_json. Full serializer migration is Phase 18.5.alumni.degrees directly: cp/profile_wizard/_step_confirm_education.html.erb, cp/careers/_career_connect_cards.html.erb, cp/leadership/{community,welcome_message,members}.html.erb. These render but were out of scope for the core profile/search paths; tracked in BACKLOG for follow-up.AlumnusEngagementDecorator rename (not an 18.4 concern; in BACKLOG).test/services/alumni/education_profile_test.rbtest/services/alumni/education_profile_parity_test.rb (Degree-sourced vs Education-sourced output identical for same data)bin/test => 4363 runs, 11548 assertions, 0 failures, 0 errors, 3 skipsGoal: Keep API response contract stable while reading from the new model.
educations block) behind V2 or opt-in flag (deferred → BACKLOG)educations are empty during migration windoweducation_coverage_pct, legacy_fallback_pct, unmapped_school_name_count)Alumni::EducationProfile#source added — returns :education, :degree_fallback, or :none. Powers the new _source advisory field exposed by both API endpoints.Api::V1::AlumniSearchController#serialize_alumni refactored to consume profile.to_export_hash instead of inline UG/GR regex logic. Removes the duplicated degree-categorization rules and ensures Education-first sourcing per record. Eager loads expanded to include { educations: :areas_of_study } alongside the legacy degrees: { major: :college } chain.Api::AlumniController#index (legacy staff typeahead) extended to emit _source alongside the existing recent_degree payload. Contract for the four legacy keys (major_desc, college_name → short, degree_code, degree_date) preserved via Entry#as_json._source field added to both endpoints’ responses ("education" |
"degree_fallback" |
"none"). All other response keys unchanged — strictly additive. |
EducationCoverageService (app/services/education_coverage_service.rb) — computes coverage metrics (5-min cache). unmapped_school_name_count uses the broad definition: counts both educations.granting_school_name and education_areas_of_study.current_institutional_unit_name rows where the code is blank and the free-text name does not match any colleges.college_name or college_name_short. Also exposes unmapped_school_names (aggregated drill-down with sample BUID + source ID) and class-level legacy_fallback_buids./api/v1/education_coverage — new internal endpoint (API-key authenticated) returning the service output as JSON./settings/data_health — new admin dashboard rendering three coverage cards (Education coverage, Legacy Degree fallback, Unmapped school names) plus a “How these are calculated” explainer. Sidebar link added under Data Imports. Includes an expandable drill-down table of distinct unmapped school names (with occurrence count, source, sample BUID + source ID) and a CSV download (/settings/data_health/unmapped_schools.csv) so the data team can triage and fix them in bulk via the colleges admin.bin/rails alumni:legacy_fallback_buids — stopgap rake task printing one BUID per line of alumni still served by the legacy degrees table. Used until Phase 18.7 surfaces this as a filterable column on the Alumni search UI (BACKLOG entry added).?include=educations block exposing the full educations + areas_of_study structure for downstream apps that want richer data than the 18.4 check-in blob. Versioned behind an opt-in flag or a V2 namespace.education / degree_fallback / none) + results column to expose the legacy-fallback roster in the UI. Belongs with Phase 18.7 UI rollout. bin/rails alumni:legacy_fallback_buids ships in 18.5 as a stopgap.test/services/alumni/education_profile_test.rb — 3 new tests for #sourcetest/services/education_coverage_service_test.rb — service unit tests (6 tests)test/controllers/api/alumni_controller_test.rb — new file, 5 tests covering legacy typeahead + _sourcetest/controllers/api/v1/education_coverage_controller_test.rb — auth + payload shapetest/controllers/settings/data_health_controller_test.rb — auth + rendertest/controllers/api/v1/alumni_search_controller_test.rb — extended check-in blob assertion to include _source + added two new tests for the degree_fallback and none branchesbin/test ⇒ 4384 runs, 11605 assertions, 0 failures, 0 errors, 3 skipsGoal: Move all stats-page aggregation logic from degrees → majors → colleges joins to a shared Education::AggregateScope query object that is Education-first with Degree fallback per BUID. Includes a side-by-side ?source=legacy|education toggle so staff can validate parity before legacy is removed.
Education::AggregateScope query object (set-based UNION; Education-first w/ Degree fallback per BUID)test/services/education/aggregate_scope_parity_test.rb) comparing degree-derived vs education-derived counts for each migrated queryAlumni model scopes migrated invisibly to UNION SQL: with_degrees, without_degrees, filter_by_college, filter_by_fiscal_yearStatisticsController — 7 query patterns migrated (fiscal year, presidential era, top 25 majors, college breakdown, generation cohort, population counts, degrees CSV)EngagementStatsController — 3 college/year filter sites migratedEngagementStats::BaseService (build_filtered_engagement_scope, build_engaged_degree_scope, build_all_degree_scope)EngagementStats::DemographicsService — 7 aggregations migrated (year/level, college, major-with-college, top-25 majors filtered to concentration_level = 'major')?source=legacy|education on /statistics, /engagement_stats, demographics tabEducationCoverageServiceaggregate_stats_v2:* (5-min TTL during 18.6; bumped to 1-hour in 18.7); invalidated by both EducationImportBatch and DegreeImportBatch completion + the existing /engagement_stats/clear_cache actionalumni.degrees to Alumni::EducationProfile (cp/profile_wizard/_step_confirm_education, cp/careers/_career_connect_cards, cp/leadership/{community,welcome_message,members}) — carried from 18.4 backlogalumni_exporter, event_rsvp_converter, event_converter_controller) → 18.7AlumniFilterService population logic full migration → 18.7aggregate_stats_v2:* namespace → 18.8degrees / majors / colleges tables and supporting code → 18.8Goal: Migrate the data-export surfaces (CSVs, CRM converters) from degrees → majors → colleges to the Education::AggregateScope + Alumni::EducationProfile stack established in 18.6. Stats pages are already cut over by this point; this phase is about contracts that flow to downstream consumers (Advancement Services CSVs, CRM event mapping).
Csv::AlumniExporter migrated (preserve ug_college, gr_college, ug_program, gr_program, etc. column contract — pull from EducationProfile.to_export_hash)Csv::EventRsvpConverter migrated (UG/GR classification via EducationProfile, not direct alumni.degrees.select { ... })Tools::EventConverterController migrated (per-attendee degree extraction)AlumniFilterService population logic moved fully behind Education::AggregateScopeCsv::CurrentStudentImporter guard check audited (verify Education-or-Degree presence, not Degree-only)aggregate_stats_v2:* stays in placeGoal: Move per-enrollment current-student data (school, program, intended degree, expected graduation year, per-record student status) out of alumni denormalized columns and into the educations table where it naturally belongs. Retire the separate Csv::CurrentStudentImporter in favor of a single education-feed pipeline.
Why: Today an alumni who completed a BBA and is enrolled in an MBA has two educations rows but only one alumni.student_status column. The single-column model can’t represent “awarded + currently enrolled” simultaneously. Worse, the separate current-student CSV importer writes denormalized fields to alumni that get overwritten or go stale relative to the education records. Moving per-enrollment data to educations makes each row self-describing and eliminates a whole class of stale-data bugs.
Add to educations:
expected_graduation_year integer — populated from “Preferred Year” CSV column when date_issued is NULLstudent_status string — per-record CRM status (awarded / pending / withdrawn)Remove from alumni (after all read paths migrated):
current_school_code, current_program_desc, intended_degree_code, expected_graduation_yearstudent_status on alumni as a rolled-up summary (awarded is a one-way ratchet)When processing each education row, update alumni.student_status per this table:
Row student_status |
date_issued |
Effect on alumni.student_status |
|---|---|---|
awarded |
present | Always upgrade to awarded |
awarded |
NULL | Always upgrade to awarded |
pending |
NULL | Set pending only if not already awarded |
withdrawn |
NULL | Set withdrawn only if not already awarded |
The contact importer’s student_status write path follows the same rule (never downgrades awarded).
expected_graduation_year + student_status columns to educations (and indexes)Csv::EducationImporter: accept preferred_year + student_status columns; write to education record; apply ratchet rule to alumni.student_statusCsv::AffinaquestContactImporter: enforce one-way awarded ratchet (never downgrade)Alumni model:
current_student? to check educations.where(date_issued: nil).exists? (independent of student_status)current_students scope to use Education joincurrent_enrollment_display to pull from in-progress educationsAlumni::STUDENT_STATUSES validation reference if no longer neededAlumni::EducationProfile: enrollment display reads from educations.where(date_issued: nil); remove fallback to alumni.current_* fieldsCsv::CurrentStudentImporter:
Settings::CurrentStudentsController + routes + viewscurrent_school_code, current_program_desc, intended_degree_code, expected_graduation_year from alumni (separate deploy after new code is stable)educations (purely additive)alumnialumni columns ships as a separate, second deploy after step 1-6 have been live and stablenon_degreed_alumni scope semantics (currently uses student_status: ["withdrawn", nil]) — may need re-evaluation once educations-with-withdrawn-status existeducations.expected_graduation_year and educations.student_status with indexesEducation.in_progress scope and migrated alumni enrollment read pathsCsv::EducationImporter to write per-education enrollment fields and apply one-way awarded ratchet logicCsv::AffinaquestContactImporterCsv::CurrentStudentImporter and removed all related controller, jobs, routes, views, tests, and rake task surface area4429 runs, 0 failures, 0 errors, 3 skipsGoal: Complete UI migration, retire legacy dependencies, and ship the public-facing UX changes (data-source filter, search columns) that depend on the new model being fully primary.
Decision (freeze, not drop): Per direction, Phase 18.9 freezes the legacy
degreestable — all read paths move to the Education model exclusively, but theAlumni::EducationProfile→degrees fallback and the physicaldegreestable remain until a later cleanup tag once Education coverage is ≥ 99%.collegesandmajorsare retained as active reference tables (collegesis the canonical college-code→label source;majorsbacks the major dropdown, community naming, and banner-import validation) and are not slated for removal. Work is grouped: B (remove toggle/banner/source plumbing), C (migrate per-record + filter reads), A (profile/areas-of-study display polish), D (freeze importer write paths).
shared/_stats_source_toggle and shared/_stats_coverage_banner. Removed ?source handling, @source/education_source?, legacy SQL branches, and source: args from StatisticsController, EngagementStatsController, EngagementStats::{BaseService,OverviewService,DemographicsService,ActivityPairsService}. Collapsed the aggregate_stats_v2:* cache namespace back to engagement_stats_*. Removed the *_legacy Alumni scopes and the legacy source test.alumni.degrees read to Alumni::EducationProfile / Education::AggregateScope: engagement-stats alumni tables, alumni/top_engaged, the stats year-filter dropdown, Champion verification show + search results, the Champion show recent-degree block, Cp::CommunityDetectionService (detection loops + member finders), Cp::Community#eligible_for?, Cp::HomeHelper grad years, Cp::AlumniLikeMeService scoring, Csv::AlumniExporter, Csv::EventRsvpConverter, Tools::EventConverterController, Champions::CommunitiesController, and LegacyVerificationService#match. Also migrated EngagementStatsController#calculate_top_alumni_data_optimized (missed in B), removed the dead fiscal_year_sql helper, and migrated the filter joins: Alumni.filter_by_name year filter, Cp::DirectoryController#load_colleges/#grad_years_by_decade, Cp::CareerConnectService cluster matching, and Cp::CommunityMatchingService.find_matching_champions (college + major).Alumni::EducationProfile::Privacy::NONE; display reads keep inferred privacy.EducationProfile→degrees fallback and EducationCoverageService raw Degree counts.find_matching_champions regression test for an Education-only champion (no Degree row).Degree.writes_frozen? (backed by WRITES_FROZEN = true) and Degree::FROZEN_MESSAGE pointing users to the Educations CRM import. Guarded every degree write: Csv::AlumniImporter.import_degrees is now a no-op returning { created: 0, frozen: true }; Csv::BannerImporter#commit_rows still creates/updates alumni but increments @skipped_degrees instead of writing degree rows; Settings::AlumniController#import_degrees/#import_degrees_commit short-circuit the degree-write block and surface FROZEN_MESSAGE. Added amber frozen banners to upload_degrees + upload_banner views.Alumni::EducationProfile now carries areas_of_study on each Entry (built from Education#areas_of_study), with an areas_of_study_summary that groups by concentration_level (major/minor/concentration), labels + pluralizes each group, orders major→minor→concentration, and joins with •. Rendered under each education entry on the Lookup alumni show page and the Champion Portal directory profile (respecting education privacy). Degree-fallback entries carry no areas and render nothing.AlumniImporter no-op test, updated Banner importer + Banner controller tests to assert frozen behavior, and 5 EducationProfile area-of-study tests.education_areas_of_study.major_code: New nullable, indexed column sourced from the CRM “Area of Study: External Id” field — the program (major) code, valid for majors, minors, and concentrations. Csv::EducationAreaOfStudyImporter reads it as the appended 8th CSV column (upcased/trimmed), surfaces it in the import preview table, persists it on commit, and includes it in the no-op comparison so re-imports stay idempotent.Alumni.filter_by_major: Migrated from the frozen degrees table to match alumni via education_areas_of_study.major_code joined through educations. The alumni search/stats major dropdown already passes major_code, so it matches directly with no name fuzzing. This was the last read path on degrees other than the intentional EducationProfile→degrees fallback.filter_by_major model tests (matches major + concentration by code, none for unknown code, ignores blank).education / degree_fallback / none) with matching results column — supersedes the alumni:legacy_fallback_buids rake task (deferred)?source=legacy toggle removed from stats pages (Group B)aggregate_stats_v2:* cache namespace collapsed back to engagement_stats_* (Group B)Degree.writes_frozen?; importers + Settings controller skip degree rows, alumni records still created/updated) (Group D)Degree model marked deprecated, removal scheduled for a separate clean-up tag (later cleanup tag)docs/CHANGELOG.mddocs/development/MODEL_RELATIONSHIPS.mddocs/planning/champion-portal/phases/README.mdapp/controllers/champions/roadmap_controller.rb status + sub-phase entriesBefore implementation starts, complete the Sub-Phase Planning Checkpoint: