Status: ✅ Complete — all five sub-phases built July 28, 2026 Effort Class: Medium Prerequisites: Phase 18 Complete (import pattern +
Educationprecedent) Surface: Lookup Portal (staff alumni tools) —alumni,settings/alumnicontrollers/views Sequencing: Built before Phase 22 and Phase 23, despite the higher number — see §2.
Related Documents:
- ../README.md — Phase Index
- ../phase-18/README.md — The import pattern this phase mirrors
- ../phase-22/README.md — Downstream consumer; §22.4 changes because of this phase
- ../../BACKLOG.md — Deferred items
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 |
Phase 22 §22.4 is written against a premise this phase invalidates. Phase 22 currently states:
alumnihas no employment columns. Onlyemail_business. There is nocompany/job_title/industryto 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:
CrmDataChange entirely when the alum reports the employer already on file
(today it would log a redundant change request for every returning signup)old_value → new_value diff when they report a different employer, which
is far more actionable for Advancement Services than a bare assertionold_value form only for alumni with no employment record at allBuilding 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.
Per CLAUDE.md → Default Work Surface:
alumni, settings/alumniEmployment is an unnamespaced shared model (app/models/employment.rb), active by
CLAUDE.md’s shared-model ruleapp/**/cp/**, Cp::*, app/controllers/champions/**,
app/views/champions/**, or layout "champion_admin"app/controllers/champions/roadmap_controller.rb
gets the Phase 24 entry. This is the documented exception in CLAUDE.md rule 5 — phase
status source of truth. Nothing else in champions/.ChampionSignupEvent nor the held Cp::ActivityRecorder applies.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 |
,,,,,,,,.
Rows where every field is blank are skipped silently and excluded from all counts.Csv::DateParser already applies the 00-30 → 20xx, 31-99 → 19xx
window. Verified against the sample’s extremes: 4/1/84 → 1984, 7/27/26 → 2026. No new
date code.Started normalization. On → on, On or Before → on_or_before, blank → nil.
Any other value is a gap row, not a silent nil — the source could add a value later.employer_name_normalized = unaccent → downcase → strip
punctuation → collapse whitespace. Legal suffixes (LLC, Inc., PLC, LLP) are
kept; trigram search matches across them without needing them stripped, and removing
them would collide distinct entities.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_employerno longer skips, and no longer counts as a gap. Two changes made the same day, in order:
- 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 of20260728140000). At this point the row was still added togap_reasonsasblank_employer.- Removed it from
gap_reasonsentirely. 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 inflatesgap_rows, appears in thegapsarray, 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_normalizedis null for them. The search must not assume it is present.
Gap rows are downloadable as CSV from the batch page, same as educations.
| 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-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 |
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.
employments (§6) and employment_import_batchesEmployment model: belongs_to :alumni (FK buid, PK buid), validations,
before_validation :normalize_employer_nameSTART_DATE_QUALIFIERS = %w[on on_or_before].freeze with inclusion validation allowing nilby_recency (start_date DESC NULLS LAST, employer_name ASC), for_buidAlumni has_many :employments + current_employment (the most recent single row)EmploymentImportBatch model — same state machine as EducationImportBatchWhat 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)
deleted_count on employment_import_batches. §6 said “a straight copy of
education_import_batches’ column set”; per-BUID replace makes deletions a real outcome
that has to survive from scan to the batch page, so it needs its own column.Alumni has_many :employments, dependent: :destroy. Not optional given the §6 FK —
without it, destroying an alum raises PG::ForeignKeyViolation instead of cascading.
(Note: educations already has the same FK with no dependent:, so fixture alumni were
already un-destroyable; the employment test uses a purpose-built record.)Employment#start_display added here rather than in 24.4 — it is the rendering rule
from §24.4 and belongs with the qualifier it formats.Csv::EmploymentImporter — preview(file, progress:) / commit(parsed_rows, progress:),
the same public surface as Csv::EducationAreaOfStudyImporterHEADER_ALIASES (source headers are verbose and could be re-labelled)Csv::DateParser reusesource_employment_id is absent from the
file are deleted. Alumni not in the file are untouched.to_delete alongside
to_create / to_update / to_skipWhat 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)
blank_source_employment_id. §4’s table doesn’t cover a row whose
Employment: ID is blank, but that column is the upsert key — such a row cannot be written.
§4 also says rows are never silently dropped, so it gets a gap reason rather than a quiet
discard.unparseable_start_date and unknown_started_qualifier
produce a gap row and a warning, but the record is still created with a blank value —
matching the unknown_concentration precedent in Csv::EducationAreaOfStudyImporter. Only
blank_buid / missing_alumni / blank_source_employment_id skip.blank_employer is neither blocking nor a gap. It went through two revisions the same
day — first stopped skipping, then removed from gap_reasons altogether — and now only
shows as a per-row preview warning. See the §4 revision note for the full history and what
it costs (a blank-employer row now counts toward the per-BUID replace set).Settings::AlumniController actions: upload_employments, import_employments_preview,
and the batch quintet (show / status / commit / gap_csv / cancel), mirroring the
education batch routesupload_employments, show_employment_import_batch,
_employment_import_preview_table, _employment_import_recent_batchesdata: { turbo: false } per the CSV Export ChecklistWhat 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)
ScanEmploymentImportJob / ApplyEmploymentImportJob;
shipped as EmploymentImportScanJob / EmploymentImportApplyJob to match the existing
EducationImportScanJob / EducationImportApplyJob convention. Naming only.settings/alumni/index”. That page carries only
the legacy Banner-era links; the live navigation for the CRM imports is
app/views/settings/_sidebar.html.erb, so the entry went there (alongside Educations and
Areas of Study), plus cross-links in the upload pages’ button rows.Delete badge mixed into
a 1,000-row list is easy to scroll past. Deletions get their own red-bordered table above
the incoming rows so the destructive part of the commit is read first.alumni#show, positioned after Degrees, matching the Degrees card’s
structure and the design system’s card patternby_recencySince Aug 2013 (on) vs
Since on or before Mar 2026 (on_or_before) vs nothing when the date is null:employments — the page already does an includes, extend itWhat 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)
Employment#employer_display, rather than an empty heading. §4’s revision note makes
blank-employer rows a normal import outcome, so the card has to render one legibly — the
title and start date it came with are the reason the row was kept.Alumni.filter_by_employer(term) — ILIKE partial match on employer_name_normalizedAlumni.filter_by_fuzzy_employer(term, threshold:) — trigram similarity() fallback,
ordered by descending similarityAlumniFilterService#apply_basic_filters gains the employer branch, using the exact →
fuzzy fallback + fuzzy_applied flag pattern already established for name. No new
control flow inventedsoundex is deliberately not used for employers — it is tuned for single surnames and
produces garbage on multi-word company namesalumni_controller#search’s “did the user search for anything” guard gains :employer_search_tags partial gains an employer chipCsv::AlumniExporter gains employer + job_title columns (most recent only), plus its
two required tests per the CSV Export ChecklistWhat 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)
where(buid: Employment.where(...).select(:buid)) matches the
filter_by_affinities precedent, which has the same shape for the same reason. There is a
regression test for it.similarity() (spec §24.5)
needs a column the subquery form doesn’t expose, so the ORDER BY is
(SELECT MAX(similarity(...)) FROM employments e WHERE e.buid = alumni.buid) DESC — the
alum’s best-matching position decides their rank. Rails drops the ordering for .count,
so @total_count is unaffected.Employment.normalize_employer, the same transform the stored column went through at
import. Without it, "Prince Properties, LLC" typed verbatim would never match the value
the importer wrote. It also makes a term that normalizes to nothing (punctuation only)
return none rather than an unbounded ILIKE '%%'.Spec deviation (24.5)
target="_blank", not data-turbo="false". The
checklist names data: { turbo: false } as the required opt-out. The alumni export link
predates this phase and opts out the other accepted way —
test/views/csv_export_links_test.rb treats target="_blank" as equally valid, since Turbo
ignores new-tab links. Changing the live link’s behavior (new tab → same tab) was out of
scope for an employment phase, so the test asserts the opt-out that is actually there.td with
sm:whitespace-nowrap. Two problems surfaced once it hit real data: the icon read as
disproportionately heavy next to the rest of the row, and a long combined string couldn’t
wrap (nowrap forces the column’s minimum width to its full one-line width), so it dragged
the Name column wider and squeezed the Degree/Affinities columns beside it. It’s now two
separate lines — job title, then employer — rendered directly under the name (before
district/BUID, which swapped order to match), with the icon dropped entirely and
whitespace-normal break-words max-w-[…] on both lines so a long title/employer wraps
instead of forcing column width. The avatar switched from items-center to items-start so
it top-aligns with the name block, and the engagement scorebar moved from a sibling of the
avatar+name row (where its left edge lined up with the avatar) to inside the name column
(where it lines up with the name/title text) — same fix as the vertical-centering pass one
step up. All of this is layout only; the underlying current_employment data and the
exact → fuzzy search behavior are unchanged.| 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 |
employments rows for every non-blank row with a
resolvable BUID — verified against
samples/2026-07-28_employment_SAMPLE.csv: 77 rows parsed, 76 create, 1 missing_alumni
gap, dates spanning 1984-04-01 to 2026-07-10blank_buid, and is not imported4/1/84 imports as 1984-04-01; 7/27/26 imports as 2026-07-27Started: On or Before round-trips as on_or_before; rendering lands in 24.4alumni#show shows all current employment, most recent firstvanderbilt returns the VUMC alumni; searching vandrbilt also returns them
via the fuzzy fallback, with the fuzzy notice shownuniversal music returns the Universal Music Group alumni (partial match)employer and job_titlebin/test — 0 failures, 0 errors (5,052 runs / 14,131 assertions / 0 failures / 0 errors
after 24.5; 5,019 / 14,007 after 24.3)| 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.
docs/planning/phases/README.md — Phase 24 index rowapp/controllers/champions/roadmap_controller.rb — Phase 24 entry (pre-approved exception),
all five sub-phases marked completedocs/planning/qa/PHASE_24_LAUNCH_GUIDE.md — migrations, import run order, QA focusdocs/features/CSV_IMPORTERS.md — Employment Importer section, including the “where the
data shows up” half added with 24.5docs/planning/BACKLOG.md — §1.8 employer entity resolution + a Phase 24
deferrals table in §6docs/development/MODEL_RELATIONSHIPS.md — Alumni has_many :employments (24.1)docs/REPO_OVERVIEW.md — new tables + Csv::EmploymentImporter (24.1–24.2)docs/CHANGELOG.md — 24.1–24.3 entry under Unreleased → Added (note: the changelog
lives at docs/CHANGELOG.md, not repo root)phase-22/README.md — a ⚠️ note now flags the “alumni has no employment columns”
premise as half-superseded and lists exactly what 22.4 must change (suppress on match,
real diff on mismatch, blank old_value only when no record exists), plus the
one-to-many / nullable-employer caveat. The §22.4 design rewrite itself is left to
Phase 22 planning rather than done unilaterally here.