feat: Person name aliases — support name changes over time (marriage, widowhood) #181

Closed
opened 2026-04-06 00:25:22 +02:00 by marcel · 10 comments
Owner

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 Person entity has a single firstName / lastName. There is no way to:

  • Record that the same person was known by multiple names at different times
  • Find a person by a name they no longer use
  • Display the name history on a person's profile

Solution

1. Add a person_name_aliases table

CREATE TABLE person_name_aliases (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    person_id   UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
    last_name   VARCHAR(255) NOT NULL,
    first_name  VARCHAR(255),          -- NULL = same as persons.first_name (surname-only change)
    type        VARCHAR(50) NOT NULL,  -- see enum below
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_aliases_person_id ON person_name_aliases(person_id);
CREATE INDEX idx_aliases_last_name  ON person_name_aliases(lower(last_name));

Type enum: BIRTH, MARRIAGE, WIDOWED, DIVORCED, OTHER

The 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 MARRIAGE entry, a name retained after a husband's death is WIDOWED. Frontend translates these to the display language.

first_name is nullable. NULL means the first name did not change — the application uses persons.first_name at display and search time. last_name is always required.

The current persons.first_name / persons.last_name stays 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_aliases so that "Clara de Gruyter" surfaces Clara Cram.

SELECT DISTINCT p.*
FROM persons p
LEFT JOIN person_name_aliases a ON a.person_id = p.id
WHERE p.last_name  ILIKE '%de gruyter%'
   OR a.last_name  ILIKE '%de gruyter%';

3. Backend changes

  • Add PersonNameAlias entity + PersonNameAliasRepository
  • Extend PersonService:
    • addAlias(personId, aliasDTO)
    • removeAlias(aliasId)
    • getAliases(personId)
  • Extend person search queries to include alias matches
  • Extend DocumentService search to join aliases when matching sender/receiver names

4. Frontend changes

  • Person detail page: "Namensverlauf" section showing aliases in insertion order
  • Each row: type label (translated) + last name (+ first name if not null)
  • Add row: type dropdown + last name field + optional first name field
  • Remove row: delete button per alias (WRITE_ALL permission)

5. Migration

No destructive migration. Existing persons records stay as-is. The alias table starts empty and is populated retroactively via the person detail page.


Acceptance Criteria

  • person_name_aliases table exists with a Flyway migration
  • first_name is nullable; last_name is NOT NULL
  • Type is one of: BIRTH, MARRIAGE, WIDOWED, DIVORCED, OTHER
  • Searching for any historical last name of a person returns that person
  • Document search (sender/receiver) also matches alias last names
  • Person detail page shows a "Namensverlauf" section in insertion order
  • Users can add and remove alias entries (WRITE_ALL permission)
  • No existing person records are affected by the migration
## 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 `Person` entity has a single `firstName` / `lastName`. There is no way to: - Record that the same person was known by multiple names at different times - Find a person by a name they no longer use - Display the name history on a person's profile --- ## Solution ### 1. Add a `person_name_aliases` table ```sql CREATE TABLE person_name_aliases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, last_name VARCHAR(255) NOT NULL, first_name VARCHAR(255), -- NULL = same as persons.first_name (surname-only change) type VARCHAR(50) NOT NULL, -- see enum below created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_aliases_person_id ON person_name_aliases(person_id); CREATE INDEX idx_aliases_last_name ON person_name_aliases(lower(last_name)); ``` **Type enum:** `BIRTH`, `MARRIAGE`, `WIDOWED`, `DIVORCED`, `OTHER` The 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 `MARRIAGE` entry, a name retained after a husband's death is `WIDOWED`. Frontend translates these to the display language. `first_name` is nullable. `NULL` means the first name did not change — the application uses `persons.first_name` at display and search time. `last_name` is always required. The current `persons.first_name` / `persons.last_name` stays 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_aliases` so that "Clara de Gruyter" surfaces Clara Cram. ```sql SELECT DISTINCT p.* FROM persons p LEFT JOIN person_name_aliases a ON a.person_id = p.id WHERE p.last_name ILIKE '%de gruyter%' OR a.last_name ILIKE '%de gruyter%'; ``` ### 3. Backend changes - Add `PersonNameAlias` entity + `PersonNameAliasRepository` - Extend `PersonService`: - `addAlias(personId, aliasDTO)` - `removeAlias(aliasId)` - `getAliases(personId)` - Extend person search queries to include alias matches - Extend `DocumentService` search to join aliases when matching sender/receiver names ### 4. Frontend changes - Person detail page: "Namensverlauf" section showing aliases in insertion order - Each row: type label (translated) + last name (+ first name if not null) - Add row: type dropdown + last name field + optional first name field - Remove row: delete button per alias (WRITE_ALL permission) ### 5. Migration No destructive migration. Existing `persons` records stay as-is. The alias table starts empty and is populated retroactively via the person detail page. --- ## Acceptance Criteria - [ ] `person_name_aliases` table exists with a Flyway migration - [ ] `first_name` is nullable; `last_name` is NOT NULL - [ ] Type is one of: `BIRTH`, `MARRIAGE`, `WIDOWED`, `DIVORCED`, `OTHER` - [ ] Searching for any historical last name of a person returns that person - [ ] Document search (sender/receiver) also matches alias last names - [ ] Person detail page shows a "Namensverlauf" section in insertion order - [ ] Users can add and remove alias entries (WRITE_ALL permission) - [ ] No existing person records are affected by the migration
marcel added the featureperson labels 2026-04-06 00:25:27 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • Test strategy for the search JOIN: The extension to PersonService and DocumentService search 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_name nullable fallback is implicit state: The spec says NULL means "same as persons.first_name". This is a silent contract. I want a failing test that asserts: when an alias has first_name = NULL, the display shows the person's current first name — not blank, not null. If that test doesn't exist, someone will render null in the UI.

  • Enum type in Java, not just SQL: The issue defines the type as VARCHAR(50) in SQL. We should back it with a PersonNameAliasType Java 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.svelte and NameAliasForm.svelte as separate components before writing a single line of markup. The parent section (NameHistory.svelte or 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

  • The two affected search queries (person search + document search) should be driven by two separate failing tests. Don't add the alias JOIN to both in a single green step — make each test force its own specific change.
  • {#each aliases as alias (alias.id)} — key by alias.id not by index. If deletions reorder the list, position-based keying will corrupt local form state.
  • Define Paraglide translation keys for all five type values (person.alias.type.BIRTH etc.) before building the dropdown, not after. Translation gaps fail silently in production.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **Test strategy for the search JOIN**: The extension to `PersonService` and `DocumentService` search 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_name` nullable fallback is implicit state**: The spec says `NULL` means "same as `persons.first_name`". This is a silent contract. I want a failing test that asserts: when an alias has `first_name = NULL`, the display shows the person's current first name — not blank, not null. If that test doesn't exist, someone will render `null` in the UI. - **Enum type in Java, not just SQL**: The issue defines the type as `VARCHAR(50)` in SQL. We should back it with a `PersonNameAliasType` Java 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.svelte` and `NameAliasForm.svelte` as separate components before writing a single line of markup. The parent section (`NameHistory.svelte` or 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 - The two affected search queries (person search + document search) should be driven by two separate failing tests. Don't add the alias JOIN to both in a single green step — make each test force its own specific change. - `{#each aliases as alias (alias.id)}` — key by `alias.id` not by index. If deletions reorder the list, position-based keying will corrupt local form state. - Define Paraglide translation keys for all five type values (`person.alias.type.BIRTH` etc.) before building the dropdown, not after. Translation gaps fail silently in production.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • Where does the alias JOIN live in document search? The issue says DocumentService search must join aliases when matching sender/receiver names. Does this mean DocumentService calls PersonService.searchByName() (which internally covers aliases), or does DocumentService itself reach into the person_name_aliases table? 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 explicit sort_order column 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 mentions PersonService has a findOrCreateByAlias method used by MassImportService. 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 '%...%' without pg_trgm: The proposed index on lower(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%), a pg_trgm GIN index is the correct choice. Worth deciding now, not when the query is slow in production.

Suggestions

  • Route document search through PersonService — let PersonService own the alias-aware search, and have DocumentService call it. No cross-domain table access from DocumentService.
  • If pg_trgm search is the intent, add CREATE 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);
  • The ON DELETE CASCADE on person_id is correct and consistent with the rest of the schema. Just confirm there's no soft-delete pattern on persons that would leave orphaned aliases behind silently.
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **Where does the alias JOIN live in document search?** The issue says `DocumentService` search must join aliases when matching sender/receiver names. Does this mean `DocumentService` calls `PersonService.searchByName()` (which internally covers aliases), or does `DocumentService` itself reach into the `person_name_aliases` table? 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 explicit `sort_order` column 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 mentions `PersonService` has a `findOrCreateByAlias` method used by `MassImportService`. 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 '%...%'` without `pg_trgm`**: The proposed index on `lower(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%`), a `pg_trgm` GIN index is the correct choice. Worth deciding now, not when the query is slow in production. ### Suggestions - Route document search through `PersonService` — let `PersonService` own the alias-aware search, and have `DocumentService` call it. No cross-domain table access from `DocumentService`. - If `pg_trgm` search is the intent, add `CREATE 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);` - The `ON DELETE CASCADE` on `person_id` is correct and consistent with the rest of the schema. Just confirm there's no soft-delete pattern on `persons` that would leave orphaned aliases behind silently.
Author
Owner

🧪 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:

  • "Searching for any historical last name returns that person" — does "any" mean exact match, case-insensitive match, or partial/substring match? The SQL example uses ILIKE '%...%', but the AC doesn't commit to this. The test must assert a specific behavior.
  • What happens on a duplicate alias entry? Same last_name + type + person_id combination 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?
  • Remove alias → search behavior: The ACs say you can remove aliases but don't state: after removal, searching by the removed name should no longer return the person. This is an implicit requirement that deserves an explicit test.

Suggested Test Cases (not yet in ACs)

Layer Test name
Integration should find person by alias last name
Integration should still find person by current last name after aliases added
Integration should not find person by removed alias last name
Integration should display persons first name when alias first name is null
Integration should cascade delete all aliases when person is deleted
Integration should match document sender by alias last name in document search
Unit addAlias should throw not found when person does not exist
@WebMvcTest should return 403 when user without WRITE_ALL attempts to add alias
@WebMvcTest should return 403 when user without WRITE_ALL attempts to remove alias
E2E (Playwright) user can add alias and find person by historical name

Testability Concerns

  • The PersonNameAliasRepository will need a @DataJpaTest with Testcontainers (real PostgreSQL 16) — the alias-aware search queries involve JOINs and ILIKE that behave differently on H2. Never H2 for this.
  • The Flyway migration must run cleanly from a fresh database in CI before any test runs. If it doesn't, the schema bug is caught here, not in production.
## 🧪 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: - **"Searching for any historical last name returns that person"** — does "any" mean exact match, case-insensitive match, or partial/substring match? The SQL example uses `ILIKE '%...%'`, but the AC doesn't commit to this. The test must assert a specific behavior. - **What happens on a duplicate alias entry?** Same `last_name` + `type` + `person_id` combination 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? - **Remove alias → search behavior**: The ACs say you can remove aliases but don't state: after removal, searching by the removed name should no longer return the person. This is an implicit requirement that deserves an explicit test. ### Suggested Test Cases (not yet in ACs) | Layer | Test name | |---|---| | Integration | `should find person by alias last name` | | Integration | `should still find person by current last name after aliases added` | | Integration | `should not find person by removed alias last name` | | Integration | `should display persons first name when alias first name is null` | | Integration | `should cascade delete all aliases when person is deleted` | | Integration | `should match document sender by alias last name in document search` | | Unit | `addAlias should throw not found when person does not exist` | | @WebMvcTest | `should return 403 when user without WRITE_ALL attempts to add alias` | | @WebMvcTest | `should return 403 when user without WRITE_ALL attempts to remove alias` | | E2E (Playwright) | `user can add alias and find person by historical name` | ### Testability Concerns - The `PersonNameAliasRepository` will need a `@DataJpaTest` with Testcontainers (real PostgreSQL 16) — the alias-aware search queries involve JOINs and `ILIKE` that behave differently on H2. Never H2 for this. - The Flyway migration must run cleanly from a fresh database in CI before any test runs. If it doesn't, the schema bug is caught here, not in production.
Author
Owner

🔒 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 raw aliasId UUID 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:

// ❌ Vulnerable
public void removeAlias(UUID aliasId) {
    aliasRepository.deleteById(aliasId);
}

// ✅ Safe — verify the alias belongs to the given person
public void removeAlias(UUID personId, UUID aliasId) {
    PersonNameAlias alias = aliasRepository.findById(aliasId)
        .orElseThrow(() -> DomainException.notFound(ErrorCode.ALIAS_NOT_FOUND, "Alias not found: " + aliasId));
    if (!alias.getPersonId().equals(personId)) {
        throw DomainException.forbidden("Alias does not belong to this person");
    }
    aliasRepository.delete(alias);
}

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 needed

The 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

  • The search queries use ILIKE with JPA parameters — confirm these are parameterized (:lastName, not string concatenation). The existing queries in PersonService appear to follow this pattern, so this is likely already handled. Just verify the alias JOIN path uses the same pattern.
  • created_at on the alias table provides basic insert provenance — lightweight audit trail for free. Worth keeping.
  • No supply chain risk from new dependencies; this feature adds only new entities, no new libraries.
## 🔒 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 raw `aliasId` UUID 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: ```java // ❌ Vulnerable public void removeAlias(UUID aliasId) { aliasRepository.deleteById(aliasId); } // ✅ Safe — verify the alias belongs to the given person public void removeAlias(UUID personId, UUID aliasId) { PersonNameAlias alias = aliasRepository.findById(aliasId) .orElseThrow(() -> DomainException.notFound(ErrorCode.ALIAS_NOT_FOUND, "Alias not found: " + aliasId)); if (!alias.getPersonId().equals(personId)) { throw DomainException.forbidden("Alias does not belong to this person"); } aliasRepository.delete(alias); } ``` 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 needed** The 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 - The search queries use `ILIKE` with JPA parameters — confirm these are parameterized (`:lastName`, not string concatenation). The existing queries in `PersonService` appear to follow this pattern, so this is likely already handled. Just verify the alias JOIN path uses the same pattern. - `created_at` on the alias table provides basic insert provenance — lightweight audit trail for free. Worth keeping. - No supply chain risk from new dependencies; this feature adds only new entities, no new libraries.
Author
Owner

🎨 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.json before 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_at date 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-6 pattern 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.

## 🎨 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.json` before 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_at` date 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-6` pattern 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.
Author
Owner

⚙️ 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 alias created_at uses now() on the database server. Confirm the PostgreSQL container and production instance both run in UTC (check SHOW 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 a pg_trgm GIN index is added later on an existing populated table, that would warrant CREATE INDEX CONCURRENTLY to 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 CASCADE keeps the database consistent automatically — no application-layer cleanup needed when a person is deleted.
  • No data migration for existing persons — the alias table starts empty. Zero risk of data corruption on deploy.
  • The feature is fully backward-compatible: the existing API surface is unchanged, only new endpoints are added.
## ⚙️ 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 alias `created_at` uses `now()` on the database server. Confirm the PostgreSQL container and production instance both run in UTC (check `SHOW 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 a `pg_trgm` GIN index is added later on an existing populated table, that would warrant `CREATE INDEX CONCURRENTLY` to 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 CASCADE` keeps the database consistent automatically — no application-layer cleanup needed when a person is deleted. - No data migration for existing persons — the alias table starts empty. Zero risk of data corruption on deploy. - The feature is fully backward-compatible: the existing API surface is unchanged, only new endpoints are added.
Author
Owner

🏗️ Markus Keller — Application Architect — Discussion follow-up

Four open architectural questions worked through with the author. All resolved.


1. DocumentService domain boundary — resolved

Decision: DocumentService must not reach into person_name_aliases directly. Alias-aware name matching belongs entirely in PersonService. DocumentService calls PersonService.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 DocumentService stays ignorant of how person name matching works — future improvements to alias search are free.


2. pg_trgm GIN indexes — resolved

Decision: The proposed B-tree index on lower(last_name) is useless for the ILIKE '%...%' query pattern used in the issue. The V21 migration must:

  1. CREATE EXTENSION IF NOT EXISTS pg_trgm;
  2. Add GIN trigram indexes on the new alias table: lower(last_name)
  3. Retroactively add GIN trigram indexes on the existing persons table: 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 needed

Decision: 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:

MassImportService does not resolve historical names against the alias table. Duplicate persons created by import with historical names must be merged manually via the person detail page.


4. Insertion order — sort_order column added

Decision: Replace the implicit created_at-based ordering with an explicit sort_order INTEGER NOT NULL DEFAULT 0 column. created_at remains 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 by sort_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.

## 🏗️ Markus Keller — Application Architect — Discussion follow-up Four open architectural questions worked through with the author. All resolved. --- ### ✅ 1. DocumentService domain boundary — resolved **Decision:** `DocumentService` must not reach into `person_name_aliases` directly. Alias-aware name matching belongs entirely in `PersonService`. `DocumentService` calls `PersonService.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 `DocumentService` stays ignorant of how person name matching works — future improvements to alias search are free. --- ### ✅ 2. `pg_trgm` GIN indexes — resolved **Decision:** The proposed B-tree index on `lower(last_name)` is useless for the `ILIKE '%...%'` query pattern used in the issue. The V21 migration must: 1. `CREATE EXTENSION IF NOT EXISTS pg_trgm;` 2. Add GIN trigram indexes on the new alias table: `lower(last_name)` 3. Retroactively add GIN trigram indexes on the existing `persons` table: `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 needed **Decision:** 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:** > MassImportService does not resolve historical names against the alias table. Duplicate persons created by import with historical names must be merged manually via the person detail page. --- ### ✅ 4. Insertion order — `sort_order` column added **Decision:** Replace the implicit `created_at`-based ordering with an explicit `sort_order INTEGER NOT NULL DEFAULT 0` column. `created_at` remains 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 by `sort_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.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead — Discussion follow-up

Four open design questions worked through with the author. All resolved.


1. Mobile layout — resolved

  • Detail page: read-only text list only. No inputs. Each alias row shows type label + name as plain text, inside a "Namensverlauf" card section (standard bg-white shadow-sm border border-brand-sand rounded-sm p-6 pattern) placed in the left column of the existing lg:grid-cols-[35%_65%] layout.
  • Edit page: alias section lives below the existing PersonEditForm fields. Saved aliases render as text + delete button (not editable inputs). The "add new alias" form uses the existing md:grid-cols-2 grid — 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_at is not shown to users. It remains in the database for audit purposes only. Display order is driven by sort_order (per the architect discussion). The created_at timestamp says "I entered this on April 6th" — irrelevant to readers of a family archive.


4. Paraglide translation keys — resolved

MARRIAGE type removed from the enum entirely. The common case (married name is the current primary name) makes it redundant, and OTHER covers atypical multi-marriage scenarios.

Final enum: BIRTH, WIDOWED, DIVORCED, OTHER

Translation keys to add to messages/de.json, en.json, es.json before the dropdown is built:

Key de en es
person.alias.type.BIRTH geborene/r Birth name Nombre de nacimiento
person.alias.type.WIDOWED verwitwete/r Name as widow/widower Nombre como viuda/viudo
person.alias.type.DIVORCED geschiedene/r Name after divorce Nombre tras el divorcio
person.alias.type.OTHER Sonstiger Name Other name Otro nombre

The 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 — Discussion follow-up Four open design questions worked through with the author. All resolved. --- ### ✅ 1. Mobile layout — resolved - **Detail page:** read-only text list only. No inputs. Each alias row shows type label + name as plain text, inside a "Namensverlauf" card section (standard `bg-white shadow-sm border border-brand-sand rounded-sm p-6` pattern) placed in the left column of the existing `lg:grid-cols-[35%_65%]` layout. - **Edit page:** alias section lives below the existing `PersonEditForm` fields. Saved aliases render as text + delete button (not editable inputs). The "add new alias" form uses the existing `md:grid-cols-2` grid — 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_at` is not shown to users. It remains in the database for audit purposes only. Display order is driven by `sort_order` (per the architect discussion). The `created_at` timestamp says "I entered this on April 6th" — irrelevant to readers of a family archive. --- ### ✅ 4. Paraglide translation keys — resolved `MARRIAGE` type removed from the enum entirely. The common case (married name is the current primary name) makes it redundant, and `OTHER` covers atypical multi-marriage scenarios. **Final enum:** `BIRTH`, `WIDOWED`, `DIVORCED`, `OTHER` Translation keys to add to `messages/de.json`, `en.json`, `es.json` before the dropdown is built: | Key | de | en | es | |---|---|---|---| | `person.alias.type.BIRTH` | geborene/r | Birth name | Nombre de nacimiento | | `person.alias.type.WIDOWED` | verwitwete/r | Name as widow/widower | Nombre como viuda/viudo | | `person.alias.type.DIVORCED` | geschiedene/r | Name after divorce | Nombre tras el divorcio | | `person.alias.type.OTHER` | Sonstiger Name | Other name | Otro nombre | --- The 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.
Author
Owner

🎨 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 Personendaten card and the PersonDangerZone.

[ Personendaten card   ]  ← unchanged
[ Namensverlauf card   ]  ← new
[ Danger zone          ]  ← unchanged
[ Save bar             ]

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.

## 🎨 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 `Personendaten` card and the `PersonDangerZone`. ``` [ Personendaten card ] ← unchanged [ Namensverlauf card ] ← new [ Danger zone ] ← unchanged [ Save bar ] ``` **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.
Author
Owner

Implementation Complete

Branch feat/issue-181-person-name-aliases — 11 commits, all tests green (676 backend, 213 frontend).

Backend (7 commits)

  • V21 migration: person_name_aliases table, pg_trgm extension, GIN trigram indexes on alias + existing persons tables
  • Entity layer: PersonNameAlias entity, PersonNameAliasType enum (BIRTH, WIDOWED, DIVORCED, OTHER), PersonNameAliasDTO, ALIAS_NOT_FOUND error code
  • Service: getAliases, addAlias (auto-incrementing sort_order), removeAlias (IDOR-protected) — 7 unit tests
  • Controller: GET/POST/DELETE /api/persons/{id}/aliases — 5 controller tests
  • Person search: LEFT JOIN aliases in searchByName (JPQL) and searchWithDocumentCount (native SQL) — 4 integration tests
  • Document search: Sender + receiver alias matching via entity-graph JPA navigation in DocumentSpecifications.hasText — 2 integration tests

Frontend (4 commits)

  • i18n: 16 new keys per language (de/en/es) — alias types, section labels, form labels, delete confirmation
  • API types: Regenerated from OpenAPI spec
  • Detail page: NameHistoryCard — read-only alias list in left column with type labels and firstName fallback
  • Edit page: NameHistoryEditCard — alias list with delete (confirmation modal) + add form (type dropdown + name fields), outside the main form tag

Known 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.

## Implementation Complete Branch `feat/issue-181-person-name-aliases` — 11 commits, all tests green (676 backend, 213 frontend). ### Backend (7 commits) - **V21 migration**: `person_name_aliases` table, `pg_trgm` extension, GIN trigram indexes on alias + existing persons tables - **Entity layer**: `PersonNameAlias` entity, `PersonNameAliasType` enum (BIRTH, WIDOWED, DIVORCED, OTHER), `PersonNameAliasDTO`, `ALIAS_NOT_FOUND` error code - **Service**: `getAliases`, `addAlias` (auto-incrementing sort_order), `removeAlias` (IDOR-protected) — 7 unit tests - **Controller**: GET/POST/DELETE `/api/persons/{id}/aliases` — 5 controller tests - **Person search**: LEFT JOIN aliases in `searchByName` (JPQL) and `searchWithDocumentCount` (native SQL) — 4 integration tests - **Document search**: Sender + receiver alias matching via entity-graph JPA navigation in `DocumentSpecifications.hasText` — 2 integration tests ### Frontend (4 commits) - **i18n**: 16 new keys per language (de/en/es) — alias types, section labels, form labels, delete confirmation - **API types**: Regenerated from OpenAPI spec - **Detail page**: `NameHistoryCard` — read-only alias list in left column with type labels and firstName fallback - **Edit page**: `NameHistoryEditCard` — alias list with delete (confirmation modal) + add form (type dropdown + name fields), outside the main form tag ### Known 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.
Sign in to join this conversation.
No Label feature person
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#181