alumni_lookup

Phase 24 — Alumni Employment Data

Status: ✅ Complete — all five sub-phases built July 28, 2026 Effort Class: Medium Prerequisites: Phase 18 Complete (import pattern + Education precedent) Surface: Lookup Portal (staff alumni tools) — alumni, settings/alumni controllers/views Sequencing: Built before Phase 22 and Phase 23, despite the higher number — see §2.

Related Documents:


Table of Contents

  1. Overview
  2. Why This Runs Before Phases 22 and 23
  3. Surface Check
  4. Source Data Contract
  5. Goals & Non-Goals
  6. Schema Changes
  7. Sub-Phase Breakdown
  8. Decisions Made During Planning
  9. Acceptance Criteria
  10. Testing Requirements
  11. Documentation Updates

1. Overview

Alumni currently have no employment data in the Lookup Portal. Advancement Services maintains current employment in BruinQuest and can export it, but the export has never been ingested. Staff cannot answer “which of our alumni work at HCA?” — a question that comes up constantly for corporate partnerships, event hosting, and career-connection asks.

This phase adds a first-class employments table sourced from the CRM export, an import that mirrors the Phase 18.3 educations pipeline, employment display on the alumni profile and search results, and a fuzzy employer search in the main alumni filter.

One-to-many, current-only. An alum may hold more than one current position (the sample export has three Fulchers at Fulcher Investment Properties and several alumni with overlapping roles). The table stores only current employment — it is not an employment history. Where multiple rows exist:

Context Rule
Alumni profile card Show all, ordered by start_date DESC NULLS LAST
Search result row Show only the most recent (first by that same ordering)
CSV export Show only the most recent, in two columns

2. Why This Runs Before Phases 22 and 23

Phase 22 §22.4 is written against a premise this phase invalidates. Phase 22 currently states:

alumni has no employment columns. Only email_business. There is no company/job_title/industry to diff against, so employment cannot produce a conventional old→new change.

…and therefore models signup-reported employment as a CrmDataChange row with a blank old_value — “the alum reports they now work at X,” not a diff.

Once employments exists, that premise is only half true. alumni still has no employment columns (and should not — see §5 Non-Goals), but there is now a CRM-sourced current employment record to compare a signup’s self-reported company / job_title against. Phase 22.4 can then:

Building Phase 22 first would ship an employment capture path that needs immediate rework. Phase 23 (naming unwind) touches route and label surfaces this phase adds to, so it also wants to come after.

Numbering rationale: this is not folded into Phase 18. Phase 18 is Complete and shipped in v1.0.65; reopening a closed phase muddies its completion record and its roadmap status. Employment is also a distinct data domain — it is not education, does not join through Major/College, and has its own import, filter, and display. A new phase number executed out of order is the cheaper bookkeeping cost, and Phase 23’s own header shows the churn that renumbering causes.


3. Surface Check

Per CLAUDE.md → Default Work Surface:


4. Source Data Contract

Sample: docs/planning/phases/phase-24/samples/ (add the sanitized export).

CSV Header Maps To Notes
Contact: BUID buid FK to alumni.buid. Blank in at least one sample row → gap
Contact: Last Name Ignored. Identity resolves on BUID only
Contact: First Name Ignored. Sample contains Nanette (Deceased) in the name field
Employment: ID source_employment_id Salesforce 15/18-char id (a14Uq000004GS3i). Upsert key
Employment: Employment source_employment_number Source’s display key (e252537). Stored, not keyed on
Employer Name employer_name Free text. Commas are quoted in source ("Prince Properties, LLC")
Title job_title Frequently blank (~40% of sample). Nullable
Started start_date_qualifier Enum: on, on_or_before, or nil. Precision flag, not a date
Start Date start_date M/D/YY. Parsed by existing Csv::DateParser. Frequently blank

Parsing rules

Gap reasons

Mirrors Csv::EducationAreaOfStudyImporter’s gap report — rows are never silently dropped:

Reason Trigger
blank_buid Contact: BUID empty but the row has data (present in the sample)
missing_alumni BUID has no matching alumni row
blank_source_employment_id Employment: ID empty — the upsert key is missing, so the row cannot be written (added during 24.2)
unparseable_start_date Start Date present but Csv::DateParser returns nil
unknown_started_qualifier Started is a value other than the two known ones

blank_buid, missing_alumni and blank_source_employment_id are blocking — those rows are skipped, because there is no alum to attach to or no upsert key. unparseable_start_date and unknown_started_qualifier are reported but not blocking: the record imports with a blank value and the gap report tells the data team what to fix upstream, matching the unknown_concentration precedent in Csv::EducationAreaOfStudyImporter.

A blank Employer Name is neither. It imports silently — not in this table at all — with only a per-row warning in the preview. See the revision note below for why.

Revised July 28, 2026 — blank_employer no longer skips, and no longer counts as a gap. Two changes made the same day, in order:

  1. Stopped skipping the row. The original contract read “the record has nothing to search on” and skipped it. In the real export, blank employer names are one of the largest buckets, and dropping them discarded the job title, start date and Salesforce id that came with them — data with value on the profile card even when the employer is missing. Those rows now import with a null employer_name (the column is nullable as of 20260728140000). At this point the row was still added to gap_reasons as blank_employer.
  2. Removed it from gap_reasons entirely. With the row importing cleanly, keeping it in the gap report stopped making sense: unlike an unresolved BUID or an unparseable date, there is nothing upstream to chase — the CRM simply doesn’t have an employer for that person. A blank employer no longer inflates gap_rows, appears in the gaps array, or shows in the gap CSV. The preview still names it via a per-row warning (“No employer name — imported for the title/date, but it will not match an employer search”), which is the only place it’s visible now.

Consequence (stands from step 1): a blank-employer row is a usable row, so it counts toward the per-BUID replace set (§8) — an alum whose export row has a blank employer will have their other rows replaced, where previously the row was rejected and they were left alone.

Consequence for 24.5: these rows can never match an employer search, and employer_name_normalized is null for them. The search must not assume it is present.

Gap rows are downloadable as CSV from the batch page, same as educations.


5. Goals & Non-Goals

Goals

Goal Definition of Done
CRM employment is importable and re-importable Second upload of the same file reports 0 created / 0 updated / all no-change
Stale employment is removed, not accumulated An alum whose file rows shrink from 2 to 1 ends with exactly 1 row
Staff can see where an alum works Employment card on alumni#show, all current rows, reverse-chronological
Staff can find alumni by employer employer filter on alumni search, tolerant of partial input and misspelling
Search results show employment Most recent employer + title under the district line
Exports carry employment Csv::AlumniExporter gains employer + job_title

Non-Goals

Non-Goal Rationale
Employment history Source feed is current-employment-only; a history table would be populated with fiction
Employment columns on alumni Same rule Phase 22 established: alumni is Belmont-provided source data and stays clean. A joined table is the correct shape for one-to-many anyway
Staff editing employment in the app Import-only in this phase. Manual edits would be silently reverted by the next import unless field protection is designed first — that is CrmDataChange territory, and belongs with Phase 22
Employer entity resolution / canonical employer table “VUMC Vanderbilt University Medical Center” appearing 12 times is one string, not 12 entities, but deduplicating into an employers table is a separate project. Fuzzy search solves the immediate need
Industry classification Not in the source export
Employment on any belmontalum.com / Cp:: surface Held surface
Wiring Phase 22.4’s signup diff This phase provides the data; Phase 22 consumes it

6. Schema Changes

One new table. No changes to alumni.

create_table :employments do |t|
  t.string  :buid,                     null: false
  t.string  :source_employment_id,     null: false   # a14Uq000004GS3i
  t.string  :source_employment_number                # e252537
  # Both nullable as of 20260728140000 — see the §4 revision note. Shipped
  # null: false in 20260728120000 and relaxed in the same phase.
  t.string  :employer_name
  t.string  :employer_name_normalized
  t.string  :job_title
  t.date    :start_date
  t.string  :start_date_qualifier                    # on | on_or_before | nil
  t.timestamps
end

add_index :employments, :source_employment_id, unique: true
add_index :employments, :buid
add_index :employments, [:buid, :start_date]
add_index :employments, :employer_name_normalized,
          using: :gin, opclass: :gin_trgm_ops
add_foreign_key :employments, :alumni, column: :buid, primary_key: :buid
Decision Why
FK employments.buid → alumni.buid Mirrors educations.buid; the DB enforces what the gap report catches at import time
Unique on source_employment_id Idempotent upsert key, mirrors education_areas_of_study.person_area_of_study_id
GIN trgm index on the normalized name The employer filter runs similarity(); without it, a sequential scan on every keystroke-driven search. No existing table has one — this is a new pattern in the codebase, justified by the search being trigram-first rather than trigram-as-fallback
start_date_qualifier as a string, not boolean The source has two values today and could add a third; a boolean would need a migration

Also new: employment_import_batches — a straight copy of education_import_batches’ column set (kind is unnecessary here; single kind). See §7.


7. Sub-Phase Breakdown

24.1 — Schema & Model ✅ Complete

What was implemented

File Note
db/migrate/20260728120000_create_employments.rb Table + indexes + FK exactly as §6
db/migrate/20260728120100_create_employment_import_batches.rb education_import_batches column set, minus kind, plus deleted_count
app/models/employment.rb Also Employment.normalize_employer (class method, reused by the importer) and #start_display for 24.4
app/models/employment_import_batch.rb deleted_count threaded through scan_complete! / start_applying! / record_progress! / apply_complete! / summary
test/fixtures/employments.yml john_doe ×2 (exercises by_recency + replace), jane_smith ×1 undated
test/models/employment_test.rb, test/models/employment_import_batch_test.rb 42 tests

Spec additions (24.1)

24.2 — Importer Service ✅ Complete

What was implemented

File Note
app/services/csv/employment_importer.rb Positional parse resolved by header name (survives column reordering), same shape as Csv::EducationAreaOfStudyImporter
app/services/employment_import_manifest_store.rb Mirror of EducationImportManifestStore
test/services/csv/employment_importer_test.rb 27 tests

How deletions travel from preview to commit. Deletions are emitted as synthesized action: :delete rows appended to the same parsed_rows array, carrying the employment_id to remove. This keeps one manifest, one commit loop, and one progress denominator — and it means the preview can list the doomed rows, not just count them.

Spec additions (24.2)

24.3 — Import UI ✅ Complete

What was implemented

File Note
config/routes.rb 7 routes under settings/alumni/, mirroring the education batch block
app/controllers/settings/alumni_controller.rb 7 actions; status JSON adds deleted_count, and auto-fails batches stale >30 min (R14/R15 OOM catch)
app/jobs/employment_import_scan_job.rb, app/jobs/employment_import_apply_job.rb Pass 1 / pass 2
app/views/settings/alumni/upload_employments.html.erb Amber banner stating the replace semantics up front
app/views/settings/alumni/show_employment_import_batch.html.erb Reuses the import-progress Stimulus controller; Deleted row in the summary turns red when non-zero
app/views/settings/alumni/_employment_import_preview_table.html.erb Red banner + a dedicated “Records to be removed” table above the incoming rows
app/views/settings/_sidebar.html.erb “Employment (CRM)” under Data Imports
test/controllers/settings/alumni_controller_employments_test.rb (28), test/jobs/employment_import_*_job_test.rb (14) Includes the a[href*="gap"][data-turbo="false"] link-markup assertion

Spec deviations (24.3)

24.4 — Profile Display ✅ Complete

What was implemented

File Note
app/views/alumni/show.html.erb Employment card between Degrees and Engagement Details. Same shell as Degrees (bg-white rounded-xl shadow-sm border, material-icons header, bg-gray-50 rounded-lg border-l-4 rows); empty state uses the work_off icon in the Degrees empty-state layout
app/controllers/alumni_controller.rb employments: [] added to the existing show includes
test/controllers/alumni_controller_test.rb Populated card (both of John’s positions + Since Aug 2019), empty state, and the undated case asserting no Since line

Spec additions (24.4)

24.5 — Employer Search & Result Display ✅ Complete

What was implemented

File Note
app/models/alumni.rb filter_by_employer + filter_by_fuzzy_employer, both matching through a subquery on employments rather than a join
app/services/alumni_filter_service.rb Employer branch in apply_basic_filters; filter_for_search preloads employments
app/controllers/alumni_controller.rb :employer permitted and added to the search guard
app/views/alumni/search.html.erb Employer input in Advanced Search Options; title + employer rendered directly under the name (LinkedIn-style), ahead of district/BUID
app/views/alumni/_search_tags.html.erb Employer chip
app/services/csv/alumni_exporter.rb employer + job_title columns after the email block; employments preloaded
test/models/alumni_test.rb (9), test/services/alumni_filter_service_test.rb (6), test/controllers/alumni_controller_test.rb (8), test/services/csv/alumni_exporter_test.rb (4) Includes the no-duplicate-row test and the CSV link-markup test

Spec additions (24.5)

Spec deviation (24.5)


8. Decisions Made During Planning

Question Decision Rationale
Phase number 24, executed before 22 and 23 Phase 18 is Complete and shipped; reopening it muddies its record. Employment is a distinct domain with its own table, import, and search
“Replace the existing one” semantics Per-BUID replace For every BUID in the file, the file’s rows are that alum’s complete current set; alumni absent from the file are untouched. Correct for both full and partial exports, where a global truncate-and-sync would wipe omitted alumni
Import mechanism Background batch (Phase 18.3 pattern) Scan → reviewable preview + gap report → commit, with a polling status page. Survives Heroku’s 30s request limit at export scale
Upsert key Employment: ID (a14Uq…) The Salesforce record id is the canonical stable identifier; Employment: Employment (e252537) is a display key and is stored but not keyed on
Deletions surfaced in preview Yes, as a distinct to_delete count Per-BUID replace is destructive by design; staff must see the number before committing, not discover it after
Profile placement New card after Degrees Matches the existing card pattern and handles multi-job alumni cleanly, which an inline contact-block field cannot
Search result row Most recent only, under district Keeps row height even at 50 results/page; the profile card is where the full set lives
Employer fuzzy matching Trigram + ILIKE, no soundex Soundex is tuned for single surnames; on multi-word company names it produces false matches
Employer entity resolution Deferred Fuzzy search solves the immediate need without committing to a canonical employers table
Staff editing employment Deferred to Phase 22 Manual edits need field protection or the next import silently reverts them — that is CrmDataChange design work

9. Acceptance Criteria


10. Testing Requirements

Area Coverage
Employment model Validations, normalization, by_recency null ordering, qualifier inclusion
EmploymentImportBatch State transitions, progress, stale cleanup
Csv::EmploymentImporter Blank-row skip, each gap reason, date window extremes, per-BUID replace, re-import no-op, shrinking set, alumni-absent-from-file untouched
Jobs Scan and apply, including failure → fail!
Settings::AlumniController Upload, preview, commit, status, gap CSV, cancel
AlumniFilterService Employer exact match, fuzzy fallback, fuzzy_applied flag, combined with other filters
Alumni scopes filter_by_employer, filter_by_fuzzy_employer
Views Employment card renders + empty state; result row line; gap CSV link markup (a[href*="gap"][data-turbo="false"])
Csv::AlumniExporter Employment columns populated; endpoint test + link markup test

Per the testing skill: tests written as each sub-phase is built, not batched at the end.


11. Documentation Updates