feat: Person name aliases — support name changes over time (marriage, widowhood) #181
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Family members change names over time — through marriage, remarriage after widowhood, or other life events. A person currently stored as "Clara Cram" may have been born "Clara de Gruyter" and gone through intermediate names. Anyone searching for "Clara de Gruyter" today gets no results, even though the person exists.
In at least one known case a person had three distinct surnames across their lifetime. This is not an edge case for a family archive — it is the rule.
Problem Statement
The current
Personentity has a singlefirstName/lastName. There is no way to:Solution
1. Add a
person_name_aliasestableType enum:
BIRTH,MARRIAGE,WIDOWED,DIVORCED,OTHERThe type describes the life event that caused the name change — not the nature of the name itself. This avoids awkward "2. Heirat / 3. Heirat" labels: a third marriage is simply another
MARRIAGEentry, a name retained after a husband's death isWIDOWED. Frontend translates these to the display language.first_nameis nullable.NULLmeans the first name did not change — the application usespersons.first_nameat display and search time.last_nameis always required.The current
persons.first_name/persons.last_namestays as the display name (the name we currently know them by). The alias table is purely the history. Entries are displayed in insertion order — users add them chronologically.2. Extend search to cover aliases
Person search and document search (sender/receiver matching) must JOIN against
person_name_aliasesso that "Clara de Gruyter" surfaces Clara Cram.3. Backend changes
PersonNameAliasentity +PersonNameAliasRepositoryPersonService:addAlias(personId, aliasDTO)removeAlias(aliasId)getAliases(personId)DocumentServicesearch to join aliases when matching sender/receiver names4. Frontend changes
5. Migration
No destructive migration. Existing
personsrecords stay as-is. The alias table starts empty and is populated retroactively via the person detail page.Acceptance Criteria
person_name_aliasestable exists with a Flyway migrationfirst_nameis nullable;last_nameis NOT NULLBIRTH,MARRIAGE,WIDOWED,DIVORCED,OTHER👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Test strategy for the search JOIN: The extension to
PersonServiceandDocumentServicesearch touches two distinct query paths. How are we planning to write the failing test first for each? I'd expect two separate red tests — one for person search returning results via alias, one for document sender/receiver search — so each one forces a specific, minimal change.first_namenullable fallback is implicit state: The spec saysNULLmeans "same aspersons.first_name". This is a silent contract. I want a failing test that asserts: when an alias hasfirst_name = NULL, the display shows the person's current first name — not blank, not null. If that test doesn't exist, someone will rendernullin the UI.Enum type in Java, not just SQL: The issue defines the type as
VARCHAR(50)in SQL. We should back it with aPersonNameAliasTypeJava enum and@Enumerated(EnumType.STRING)on the entity field. That way the compiler catches invalid values and OpenAPI generates the right schema automatically.Component decomposition for "Namensverlauf": A list section with: a type dropdown, a last name field, an optional first name field, and a delete button per row — that's easily 50+ lines of template. I'd plan for
NameAliasRow.svelteandNameAliasForm.svelteas separate components before writing a single line of markup. The parent section (NameHistory.svelteor similar) becomes a pure orchestrator.getAliases(personId)is a read — no@Transactional: Listed alongside write methods but should be read-only. Confirm it won't carry a write transaction.Suggestions
{#each aliases as alias (alias.id)}— key byalias.idnot by index. If deletions reorder the list, position-based keying will corrupt local form state.person.alias.type.BIRTHetc.) before building the dropdown, not after. Translation gaps fail silently in production.🏗️ Markus Keller — Application Architect
Questions & Observations
Where does the alias JOIN live in document search? The issue says
DocumentServicesearch must join aliases when matching sender/receiver names. Does this meanDocumentServicecallsPersonService.searchByName()(which internally covers aliases), or doesDocumentServiceitself reach into theperson_name_aliasestable? The first option respects domain boundaries. The second is a leak. This needs to be made explicit before implementation starts.Insertion-order display relies on
created_at— is this reliable enough? If two aliases are inserted in the same second (possible in a future bulk import or data migration), display order becomes non-deterministic. An explicitsort_ordercolumn or monotonic sequence would be more honest. The issue defers dates entirely, but this is a weaker contract.PersonService.findOrCreateByAlias()— behavioral change not called out: The CLAUDE.md mentionsPersonServicehas afindOrCreateByAliasmethod used byMassImportService. Does that method now search alias last names too? If not, an Excel import with the historical name "Clara de Gruyter" will still create a duplicate person. If yes, this is a meaningful behavioral change to existing functionality that deserves a test and a callout in the PR.ILIKE '%...%'withoutpg_trgm: The proposed index onlower(last_name)is a B-tree index — it helps only for prefix matches (LIKE 'de gruyter%'). A%de gruyter%wildcard search ignores B-tree indexes entirely. If full-substring search is intended (the example in the issue uses%de gruyter%), apg_trgmGIN index is the correct choice. Worth deciding now, not when the query is slow in production.Suggestions
PersonService— letPersonServiceown the alias-aware search, and haveDocumentServicecall it. No cross-domain table access fromDocumentService.pg_trgmsearch is the intent, addCREATE EXTENSION IF NOT EXISTS pg_trgm;to the migration and replace the B-tree index with:CREATE INDEX idx_aliases_last_name_trgm ON person_name_aliases USING GIN (lower(last_name) gin_trgm_ops);ON DELETE CASCADEonperson_idis correct and consistent with the rest of the schema. Just confirm there's no soft-delete pattern onpersonsthat would leave orphaned aliases behind silently.🧪 Sara Holt — QA Engineer & Test Strategist
Questions & Observations
The acceptance criteria are solid at the happy-path level, but several edge cases and error paths are unspecified. I'd want answers before implementation starts:
ILIKE '%...%', but the AC doesn't commit to this. The test must assert a specific behavior.last_name+type+person_idcombination submitted twice. Is this silently accepted (two identical rows), rejected with a constraint violation, or deduplicated by the service? The schema has no UNIQUE constraint on(person_id, last_name, type)— is that intentional?Suggested Test Cases (not yet in ACs)
should find person by alias last nameshould still find person by current last name after aliases addedshould not find person by removed alias last nameshould display persons first name when alias first name is nullshould cascade delete all aliases when person is deletedshould match document sender by alias last name in document searchaddAlias should throw not found when person does not existshould return 403 when user without WRITE_ALL attempts to add aliasshould return 403 when user without WRITE_ALL attempts to remove aliasuser can add alias and find person by historical nameTestability Concerns
PersonNameAliasRepositorywill need a@DataJpaTestwith Testcontainers (real PostgreSQL 16) — the alias-aware search queries involve JOINs andILIKEthat behave differently on H2. Never H2 for this.🔒 Nora "NullX" Steiner — Application Security
Questions & Observations
Two concerns worth resolving before implementation, one definite and one a design decision:
1. IDOR risk in
removeAlias(aliasId)— CWE-639 (Authorization Bypass Through User-Controlled Key)If the
DELETE /api/persons/{personId}/aliases/{aliasId}endpoint takes a rawaliasIdUUID without verifying ownership, an authenticated user with WRITE_ALL can delete any alias across any person by guessing or enumerating UUIDs. The service must verify:Add a test: user with WRITE_ALL attempts to delete alias belonging to a different person → 403.
2. Access control for
getAliases(personId)— design decision neededThe AC specifies WRITE_ALL for add/remove but is silent on read access to the alias list. Should historical names (e.g. a person's birth name or post-divorce name) be readable by all authenticated users, or only by WRITE_ALL users? For a family archive this is likely fine to expose to READ_ALL, but it should be a conscious decision, not an oversight.
Other Observations
ILIKEwith JPA parameters — confirm these are parameterized (:lastName, not string concatenation). The existing queries inPersonServiceappear to follow this pattern, so this is likely already handled. Just verify the alias JOIN path uses the same pattern.created_aton the alias table provides basic insert provenance — lightweight audit trail for free. Worth keeping.🎨 Leonie Voss — UI/UX Design Lead
Questions & Observations
Mobile layout for the add-alias form: At 320px viewport, a single row with a type dropdown + last name field + optional first name field + delete button will be extremely cramped. Has a stacked mobile layout been planned? My suggestion: on mobile, the form fields stack vertically; on ≥768px, they appear inline. Touch targets on the delete button must be ≥44px regardless.
Type enum labels — translation mapping not defined: The issue says "Frontend translates these to the display language" but the translation keys aren't specified. Before implementation, define the Paraglide keys explicitly:
person.alias.type.BIRTH→ "Geburtsname" / "Birth name" / "Nombre de nacimiento"person.alias.type.MARRIAGE→ "Ehename" / "Married name" / "Nombre de casada"person.alias.type.WIDOWED→ "Name als Witwe/Witwer" / "Name as widow/widower" / ...person.alias.type.DIVORCED→ "Name nach Scheidung" / ...person.alias.type.OTHER→ "Sonstiger Name" / "Other name" / ...These should go into
messages/de.json,en.json,es.jsonbefore the dropdown is built.Insertion order without timestamps shown: Users add aliases "chronologically" but can't see when an entry was added or reorder if they enter them wrong. At minimum, consider a subtle
created_atdate shown as a tooltip or small secondary text — this helps users verify correct ordering without cluttering the layout.Suggestions
Delete button accessibility: Icon-only delete buttons (×, trash icon) require
aria-label="Alias entfernen"to satisfy WCAG SC 1.1.1. Without it, screen readers announce nothing meaningful.Confirmation before delete: Removing a name alias is destructive and meaningful (it affects search results). Consider an inline confirmation ("Wirklich entfernen?") or an undo toast rather than immediate deletion. For senior users especially, accidental taps on a delete button without confirmation are a real usability risk.
"Namensverlauf" section placement: On the person detail page, this section should sit between the basic personal data and the associated documents — it's biographical context that helps make sense of the document list below. Make sure the card uses the standard
bg-white shadow-sm border border-brand-sand rounded-sm p-6pattern consistent with the rest of the page.Empty state: When a person has no aliases yet, show a brief, friendly empty state message rather than just hiding the section or showing nothing — e.g. "Noch keine Namensänderungen erfasst." This signals the feature is available, not broken.
⚙️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
This is a clean, additive change from an infrastructure perspective. No new services, no new dependencies, no async workers. My observations are minor:
Migration naming and sequencing: Name the Flyway file explicitly to avoid conflicts with any in-flight migrations on parallel branches:
V{n}__add_person_name_aliases.sql. Check the current highest migration version before assigning a number — don't just pick the next integer without looking.TIMESTAMPTZ DEFAULT now()and timezone consistency: The aliascreated_atusesnow()on the database server. Confirm the PostgreSQL container and production instance both run in UTC (checkSHOW timezone;). Spring Boot defaults to UTC; if the DB runs in Europe/Berlin, timestamp comparisons between app-layer and DB-layer code can drift by an hour during DST transitions. Not a blocker for this feature, but worth a note in the migration comment.Index creation time: The two indexes (
idx_aliases_person_id,idx_aliases_last_name) are created on an empty table — instantaneous. No concern here. If apg_trgmGIN index is added later on an existing populated table, that would warrantCREATE INDEX CONCURRENTLYto avoid table lock. Note this now so it's not a surprise.No new container or service required: This is entirely within the existing PostgreSQL + Spring Boot footprint. The Docker Compose stack doesn't change. No deployment coordination needed beyond the standard "deploy new JAR, Flyway runs on startup" flow.
What Looks Good
ON DELETE CASCADEkeeps the database consistent automatically — no application-layer cleanup needed when a person is deleted.🏗️ Markus Keller — Application Architect — Discussion follow-up
Four open architectural questions worked through with the author. All resolved.
✅ 1. DocumentService domain boundary — resolved
Decision:
DocumentServicemust not reach intoperson_name_aliasesdirectly. Alias-aware name matching belongs entirely inPersonService.DocumentServicecallsPersonService.findMatchingPersonIds(name), which internally handles the alias JOIN, and uses the returned IDs to filter documents by sender/receiver.This respects the domain boundary rule in CLAUDE.md and means
DocumentServicestays ignorant of how person name matching works — future improvements to alias search are free.✅ 2.
pg_trgmGIN indexes — resolvedDecision: The proposed B-tree index on
lower(last_name)is useless for theILIKE '%...%'query pattern used in the issue. The V21 migration must:CREATE EXTENSION IF NOT EXISTS pg_trgm;lower(last_name)personstable:lower(first_name),lower(last_name),lower(alias)— the existing person search is already running unindexed against these columns.One migration fixes both the new and the existing problem.
✅ 3.
findOrCreateByAlias()in MassImportService — resolved, no change neededDecision: Do not extend
findOrCreateByAlias()to search the alias table. The ODS import file does not carry the mapping "Clara de Gruyter = Clara Cram" — that knowledge doesn't exist in the import data. Extending the method would require a circular dependency (alias must exist before import) and risk false matches.The correct workflow is: import → identify duplicate persons → merge manually → add alias retroactively.
PR description must document this as a known limitation:
✅ 4. Insertion order —
sort_ordercolumn addedDecision: Replace the implicit
created_at-based ordering with an explicitsort_order INTEGER NOT NULL DEFAULT 0column.created_atremains for audit purposes but is demoted to a tiebreaker.Insert logic sets
sort_order = SELECT MAX(sort_order) + 1 FROM person_name_aliases WHERE person_id = ?. Display query orders bysort_order ASC, created_at ASC.This gives deterministic ordering always and makes a future drag-to-reorder UI implementable without a schema change.
Overall the feature is well-scoped and additive. The four decisions above should be reflected in the Flyway migration, the service layer, and the PR description before implementation starts.
🎨 Leonie Voss — UI/UX Design Lead — Discussion follow-up
Four open design questions worked through with the author. All resolved.
✅ 1. Mobile layout — resolved
bg-white shadow-sm border border-brand-sand rounded-sm p-6pattern) placed in the left column of the existinglg:grid-cols-[35%_65%]layout.PersonEditFormfields. Saved aliases render as text + delete button (not editable inputs). The "add new alias" form uses the existingmd:grid-cols-2grid — type dropdown + last name on one row, optional first name + submit button on the second. Mobile stacks to single column automatically — no extra breakpoint logic needed.✅ 2. Delete confirmation — resolved
Decision: modal confirmation before alias delete, consistent with the existing popup pattern already used in the app.
Modal content: title ("Alias entfernen?"), one line of context ("Dieser Name wird aus der Suche entfernt."), two buttons — "Entfernen" (destructive) and "Abbrechen". No inline confirmation — this pattern breaks down on mobile alias rows.
Note: when a unified deletion pattern is introduced app-wide, this modal is the reference implementation.
✅ 3. Timestamp visibility — resolved
Decision:
created_atis not shown to users. It remains in the database for audit purposes only. Display order is driven bysort_order(per the architect discussion). Thecreated_attimestamp says "I entered this on April 6th" — irrelevant to readers of a family archive.✅ 4. Paraglide translation keys — resolved
MARRIAGEtype removed from the enum entirely. The common case (married name is the current primary name) makes it redundant, andOTHERcovers atypical multi-marriage scenarios.Final enum:
BIRTH,WIDOWED,DIVORCED,OTHERTranslation keys to add to
messages/de.json,en.json,es.jsonbefore the dropdown is built:person.alias.type.BIRTHperson.alias.type.WIDOWEDperson.alias.type.DIVORCEDperson.alias.type.OTHERThe feature is well-suited to the existing layout patterns. No new responsive breakpoints needed — the edit page grid handles mobile automatically. The main implementation risk is the modal confirmation component; if one doesn't exist yet as a shared component, it should be extracted as one rather than built inline.
🎨 Leonie Voss — UI/UX Design Lead — Edit page placement
One open question from the previous discussion, now resolved.
✅ Alias section placement on the edit page — resolved
Decision: the "Namensverlauf" section gets its own card, placed between the existing
Personendatencard and thePersonDangerZone.Why not inside the existing card: the existing card stays compact and focused on current biographical data. The alias section can grow (list of rows + add form) and would stretch the card unpredictably.
Why not after notes: logical grouping matters — the page reads as current identity → name history → destructive actions, which is a coherent hierarchy.
Implementation note: the alias card sits outside the
<form>tag. Add/remove alias operations are separate API calls, not part of the main person update form action. This is a clean implementation boundary — no need to wire alias state into the existing form submission.Implementation Complete
Branch
feat/issue-181-person-name-aliases— 11 commits, all tests green (676 backend, 213 frontend).Backend (7 commits)
person_name_aliasestable,pg_trgmextension, GIN trigram indexes on alias + existing persons tablesPersonNameAliasentity,PersonNameAliasTypeenum (BIRTH, WIDOWED, DIVORCED, OTHER),PersonNameAliasDTO,ALIAS_NOT_FOUNDerror codegetAliases,addAlias(auto-incrementing sort_order),removeAlias(IDOR-protected) — 7 unit tests/api/persons/{id}/aliases— 5 controller testssearchByName(JPQL) andsearchWithDocumentCount(native SQL) — 4 integration testsDocumentSpecifications.hasText— 2 integration testsFrontend (4 commits)
NameHistoryCard— read-only alias list in left column with type labels and firstName fallbackNameHistoryEditCard— alias list with delete (confirmation modal) + add form (type dropdown + name fields), outside the main form tagKnown limitation (documented per architect decision)
MassImportService does not resolve historical names against the alias table. Duplicate persons created by import with historical names must be merged manually.