feat(persons): surface personType + title in forms and detail card #333

Merged
marcel merged 27 commits from feat/issue-218-person-title-type-fields into main 2026-04-26 13:37:40 +02:00
Owner

Closes #218

Summary

  • Backend: personType is now @NotNull in PersonUpdateDTO; title (@Size max=50) added. Both are persisted on create/update. SKIP is rejected with INVALID_PERSON_TYPE. firstName is only required for PERSON type.
  • Frontend: New PersonTypeSelector segmented control (4 types, keyboard nav, ARIA). Edit and new-person forms show the selector at the top, a title field (PERSON only), and hide firstName/alias/years for non-PERSON types. PersonCard shows title in small-caps above the display name.

What changed

Backend

  • PersonUpdateDTOpersonType (@NotNull), title (@Size max=50)
  • PersonService — persists both fields; SKIP guard on create + update
  • PersonControllervalidatePersonNames() helper; firstName required only for PERSON
  • ErrorCode.INVALID_PERSON_TYPE + i18n (de/en/es)

Frontend

  • radioGroupNav.ts — ArrowLeft/Right keyboard navigation action (7 browser tests)
  • PersonTypeSelector.svelte — 4-segment radiogroup, 2×2 mobile grid, min-h-[48px], aria-live
  • PersonEditForm.svelte — type selector, title input, conditional fields per spec §4 matrix
  • Edit +page.server.ts — extracts personType + title; type-aware firstName validation
  • New-person +page.svelte + +page.server.ts — same structure as edit form
  • PersonCard.svelte — title in [font-variant:small-caps] above displayName (PERSON only)
  • SKIP→UNKNOWN normalization in edit-route load (6 unit tests)

Test results

  • Backend: 1353/1353
  • Frontend server: 377/377
Closes #218 ## Summary - **Backend**: `personType` is now `@NotNull` in `PersonUpdateDTO`; `title` (@Size max=50) added. Both are persisted on create/update. SKIP is rejected with `INVALID_PERSON_TYPE`. `firstName` is only required for PERSON type. - **Frontend**: New `PersonTypeSelector` segmented control (4 types, keyboard nav, ARIA). Edit and new-person forms show the selector at the top, a title field (PERSON only), and hide firstName/alias/years for non-PERSON types. `PersonCard` shows title in small-caps above the display name. ## What changed ### Backend - `PersonUpdateDTO` — `personType` (@NotNull), `title` (@Size max=50) - `PersonService` — persists both fields; SKIP guard on create + update - `PersonController` — `validatePersonNames()` helper; firstName required only for PERSON - `ErrorCode.INVALID_PERSON_TYPE` + i18n (de/en/es) ### Frontend - `radioGroupNav.ts` — ArrowLeft/Right keyboard navigation action (7 browser tests) - `PersonTypeSelector.svelte` — 4-segment radiogroup, 2×2 mobile grid, min-h-[48px], aria-live - `PersonEditForm.svelte` — type selector, title input, conditional fields per spec §4 matrix - Edit `+page.server.ts` — extracts personType + title; type-aware firstName validation - New-person `+page.svelte` + `+page.server.ts` — same structure as edit form - `PersonCard.svelte` — title in `[font-variant:small-caps]` above displayName (PERSON only) - SKIP→UNKNOWN normalization in edit-route load (6 unit tests) ## Test results - Backend: 1353/1353 ✅ - Frontend server: 377/377 ✅
Author
Owner

🏗️ Markus Keller — Software Architect

Verdict: ⚠️ Approved with concerns

Blockers

None — the changes are safe to merge.

Concerns

1. Breaking API contract on personType
@NotNull on PersonUpdateDTO.personType makes omitting personType in a JSON body a Bean Validation violation. The error response produced by Spring's constraint handler is the default Spring format ({ "errors": [{ "field": "personType", "message": "..." }] }), not the project's { "code": "..." } format. The frontend getErrorMessage() can't map it. In practice, the frontend always sends personType now, so there's no user-visible bug. But: any other caller (import scripts, API tests) that previously sent { "firstName": "...", "lastName": "..." } will silently break. The PersonControllerTest tests now all include personType — good — but confirm no integration caller is omitting it before deploying.

2. SKIP guard ordering in PersonService.updatePerson
The SKIP check runs before personRepository.findById(id). If a caller submits SKIP for a non-existent ID, they get a 400 instead of a 404. Minor semantic inversion — usually "does the resource exist" comes before "is the input valid".

Suggestions

3. PR scope creep — FTS fix bundled in
The to_tsquery('simple') change in DocumentRepository is a good fix, but it has no relation to person-type surfacing. Makes bisecting harder if the FTS change ever causes a regression. Ideally a separate PR, but pragmatically acceptable since the regression test is included.

4. Layering is correct
validatePersonNames in the controller handles input validation (controller concern). SKIP guard in PersonService enforces a domain invariant (service concern). That's the right split per CLAUDE.md layering rules.

## 🏗️ Markus Keller — Software Architect **Verdict: ⚠️ Approved with concerns** ### Blockers None — the changes are safe to merge. ### Concerns **1. Breaking API contract on `personType`** `@NotNull` on `PersonUpdateDTO.personType` makes omitting `personType` in a JSON body a Bean Validation violation. The error response produced by Spring's constraint handler is the default Spring format (`{ "errors": [{ "field": "personType", "message": "..." }] }`), not the project's `{ "code": "..." }` format. The frontend `getErrorMessage()` can't map it. In practice, the frontend always sends `personType` now, so there's no user-visible bug. But: any other caller (import scripts, API tests) that previously sent `{ "firstName": "...", "lastName": "..." }` will silently break. The `PersonControllerTest` tests now all include `personType` — good — but confirm no integration caller is omitting it before deploying. **2. SKIP guard ordering in `PersonService.updatePerson`** The SKIP check runs before `personRepository.findById(id)`. If a caller submits SKIP for a non-existent ID, they get a 400 instead of a 404. Minor semantic inversion — usually "does the resource exist" comes before "is the input valid". ### Suggestions **3. PR scope creep — FTS fix bundled in** The `to_tsquery('simple')` change in `DocumentRepository` is a good fix, but it has no relation to person-type surfacing. Makes bisecting harder if the FTS change ever causes a regression. Ideally a separate PR, but pragmatically acceptable since the regression test is included. **4. Layering is correct** `validatePersonNames` in the controller handles input validation (controller concern). SKIP guard in `PersonService` enforces a domain invariant (service concern). That's the right split per CLAUDE.md layering rules.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

1. page.server.test.ts tests a local copy, not production code
normalizePersonType is defined inside the test file. The actual production code in +page.server.ts inlines the same logic:

const personType = person.personType === 'SKIP' ? 'UNKNOWN' : person.personType;

These tests pass because they test a locally-defined helper that matches the production intent — but if someone later changes the inline expression in +page.server.ts, the test file keeps passing. The tests provide documentation value but no regression protection.

Fix: Extract normalizePersonType from +page.server.ts as a named export, then import it in the test file. One line change in production code, and the test binding becomes real.

Suggestions

2. Focus ring token inconsistency
PersonTypeSelector.svelte uses focus-visible:ring-brand-navy while every input in PersonEditForm.svelte uses focus-visible:ring-focus-ring. These may resolve to the same color via CSS variables, but the tokens should be consistent. Swap ring-brand-navyring-focus-ring in PersonTypeSelector.

3. untrack() pattern — correct
Used properly in all three components (PersonTypeSelector, PersonEditForm, new-person page) for one-time initialization from props without establishing a reactive dependency. This is the right Svelte 5 idiom.

4. labels record — good call
Using Record<PersonType, () => string> instead of dynamic m[person_type_${selected}]() keeps TypeScript happy without type assertions. Clean.

5. radioGroupNav tests — excellent
7 tests covering ArrowRight/ArrowLeft, wrap-around in both directions, aria-checked updates, destroy cleanup, and ignoring non-arrow keys. Nothing missing here.

6. @NotNull on DTO is a breaking change
Flagged by the architect — same concern here from a code perspective. All PersonControllerTest test bodies have been updated to include personType. Make sure the same is done in any *.http API test files.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers **1. `page.server.test.ts` tests a local copy, not production code** `normalizePersonType` is defined inside the test file. The actual production code in `+page.server.ts` inlines the same logic: ```typescript const personType = person.personType === 'SKIP' ? 'UNKNOWN' : person.personType; ``` These tests pass because they test a locally-defined helper that matches the production intent — but if someone later changes the inline expression in `+page.server.ts`, the test file keeps passing. The tests provide documentation value but no regression protection. **Fix:** Extract `normalizePersonType` from `+page.server.ts` as a named export, then import it in the test file. One line change in production code, and the test binding becomes real. ### Suggestions **2. Focus ring token inconsistency** `PersonTypeSelector.svelte` uses `focus-visible:ring-brand-navy` while every input in `PersonEditForm.svelte` uses `focus-visible:ring-focus-ring`. These may resolve to the same color via CSS variables, but the tokens should be consistent. Swap `ring-brand-navy` → `ring-focus-ring` in PersonTypeSelector. **3. `untrack()` pattern — correct** Used properly in all three components (`PersonTypeSelector`, `PersonEditForm`, new-person page) for one-time initialization from props without establishing a reactive dependency. This is the right Svelte 5 idiom. **4. `labels` record — good call** Using `Record<PersonType, () => string>` instead of dynamic `m[`person_type_${selected}`]()` keeps TypeScript happy without type assertions. Clean. **5. `radioGroupNav` tests — excellent** 7 tests covering ArrowRight/ArrowLeft, wrap-around in both directions, aria-checked updates, destroy cleanup, and ignoring non-arrow keys. Nothing missing here. **6. `@NotNull` on DTO is a breaking change** Flagged by the architect — same concern here from a code perspective. All `PersonControllerTest` test bodies have been updated to include `personType`. Make sure the same is done in any `*.http` API test files.
Author
Owner

🔐 Nora "NullX" Steiner — Security Expert

Verdict: Approved

What I checked

  • Input validation and SKIP guard placement
  • Error response information disclosure
  • SQL injection surface in the FTS change
  • RBAC coverage

Findings

1. Defense in depth on SKIP — correct
SKIP is blocked at two layers: the service throws DomainException.badRequest(INVALID_PERSON_TYPE, ...) for both create and update paths. The @NotNull constraint on personType prevents null from reaching either path. Neither layer alone is sufficient (null ≠ SKIP), but together they cover the full rejection surface.

2. Bean Validation error format leaks field names
When personType is null, Spring returns a constraint violation response that includes the field name and constraint type (e.g. "field": "personType", "defaultMessage": "must not be null"). This is mild information disclosure — it reveals DTO structure. For a family-only internal tool this is acceptable, but worth noting as a pattern to avoid on public-facing APIs.

3. Title not trimmed server-side
title is trimmed in the frontend action (formData.get('title')?.toString().trim()) but there's no equivalent trim in the controller or service layer. An API caller submitting "title": " Prof. " would persist the whitespace. Not a security issue, just a data quality gap.

4. FTS change is safe
to_tsquery('simple', regexp_replace(...)) continues to use parameterized values — no SQL injection surface introduced. The simple dictionary change is a correctness fix, not a security-relevant one.

5. RBAC unchanged
@RequirePermission(WRITE_ALL) guards on createPerson and updatePerson are untouched.

## 🔐 Nora "NullX" Steiner — Security Expert **Verdict: ✅ Approved** ### What I checked - Input validation and SKIP guard placement - Error response information disclosure - SQL injection surface in the FTS change - RBAC coverage ### Findings **1. Defense in depth on SKIP — correct** SKIP is blocked at two layers: the service throws `DomainException.badRequest(INVALID_PERSON_TYPE, ...)` for both create and update paths. The `@NotNull` constraint on `personType` prevents null from reaching either path. Neither layer alone is sufficient (null ≠ SKIP), but together they cover the full rejection surface. **2. Bean Validation error format leaks field names** When `personType` is null, Spring returns a constraint violation response that includes the field name and constraint type (e.g. `"field": "personType", "defaultMessage": "must not be null"`). This is mild information disclosure — it reveals DTO structure. For a family-only internal tool this is acceptable, but worth noting as a pattern to avoid on public-facing APIs. **3. Title not trimmed server-side** `title` is trimmed in the frontend action (`formData.get('title')?.toString().trim()`) but there's no equivalent trim in the controller or service layer. An API caller submitting `"title": " Prof. "` would persist the whitespace. Not a security issue, just a data quality gap. **4. FTS change is safe** `to_tsquery('simple', regexp_replace(...))` continues to use parameterized values — no SQL injection surface introduced. The `simple` dictionary change is a correctness fix, not a security-relevant one. **5. RBAC unchanged** `@RequirePermission(WRITE_ALL)` guards on `createPerson` and `updatePerson` are untouched. ✅
Author
Owner

🧪 Sara Holt — QA / Tester

Verdict: ⚠️ Approved with concerns

Blockers

1. page.server.test.ts doesn't test production code
The test file defines its own copy of normalizePersonType and tests that. The actual production logic in +page.server.ts is the inline ternary person.personType === 'SKIP' ? 'UNKNOWN' : person.personType. These tests would stay green even if that production line were deleted or corrupted. This is a phantom test — it gives false confidence in coverage.

Missing coverage

2. No controller-level SKIP rejection test
PersonServiceTest tests the SKIP guard in the service. PersonControllerTest adds a test for INSTITUTION without firstName, but no test for submitting personType: "SKIP" via the HTTP endpoint. A controller integration test like createPerson_returns400_whenPersonTypeIsSkip would confirm the full stack rejects it (not just the service in isolation).

3. Edit form action validation untested
The server action in edit/+page.server.ts now has type-aware validation (personType === 'PERSON' && !firstName). There are no unit or integration tests for this path. The analogous path in new/+page.server.ts is also untested. The frontend validates before submit, but the server-side is still an untested code path.

What's well-covered

  • radioGroupNav.svelte.spec.ts — 7 tests, all keyboard edge cases including destroy and non-arrow keys
  • DocumentFtsTest — new stop-word test is well-named, self-documenting, and confirms the root cause
  • PersonServiceTest — new tests for persistsPersonType, persistsTitle, and both SKIP guards
  • PersonControllerTest — updated all existing tests to include personType, added INSTITUTION without firstName test
## 🧪 Sara Holt — QA / Tester **Verdict: ⚠️ Approved with concerns** ### Blockers **1. `page.server.test.ts` doesn't test production code** The test file defines its own copy of `normalizePersonType` and tests that. The actual production logic in `+page.server.ts` is the inline ternary `person.personType === 'SKIP' ? 'UNKNOWN' : person.personType`. These tests would stay green even if that production line were deleted or corrupted. This is a phantom test — it gives false confidence in coverage. ### Missing coverage **2. No controller-level SKIP rejection test** `PersonServiceTest` tests the SKIP guard in the service. `PersonControllerTest` adds a test for INSTITUTION without firstName, but no test for submitting `personType: "SKIP"` via the HTTP endpoint. A controller integration test like `createPerson_returns400_whenPersonTypeIsSkip` would confirm the full stack rejects it (not just the service in isolation). **3. Edit form action validation untested** The server action in `edit/+page.server.ts` now has type-aware validation (`personType === 'PERSON' && !firstName`). There are no unit or integration tests for this path. The analogous path in `new/+page.server.ts` is also untested. The frontend validates before submit, but the server-side is still an untested code path. ### What's well-covered ✅ - `radioGroupNav.svelte.spec.ts` — 7 tests, all keyboard edge cases including destroy and non-arrow keys - `DocumentFtsTest` — new stop-word test is well-named, self-documenting, and confirms the root cause - `PersonServiceTest` — new tests for `persistsPersonType`, `persistsTitle`, and both SKIP guards - `PersonControllerTest` — updated all existing tests to include `personType`, added INSTITUTION without firstName test
Author
Owner

🎨 Leonie Voss — UI/UX Expert

Verdict: Approved

What I checked

  • ARIA patterns and keyboard accessibility
  • Touch target sizes
  • Visual hierarchy and typography
  • Form repopulation and error UX
  • Conditional field visibility

Findings

Accessibility — strong implementation

  • role="radiogroup" on the container + role="radio" on each button + aria-checked + roving tabindex (tabindex={selected === type ? 0 : -1}) — this is textbook WAI-ARIA 1.2 radio group pattern.
  • Arrow key navigation via radioGroupNav action handles wrap-around correctly in both directions.
  • aria-live="polite" announces type changes to screen readers without interrupting. The region only fires on changes, not on page load.

Touch targets — correct
min-h-[48px] on each button exceeds WCAG 2.5.5 (44px).

Title typography — nice
[font-variant:small-caps] for honorifics above the name in PersonCard is the right visual treatment for honorific prefixes like "Dr." or "Prof.". Gated correctly on personType === 'PERSON' && person.title.

Adaptive labels — correct
lastNameLabel switches between "Name" (INSTITUTION/GROUP) and "Nachname" (PERSON/UNKNOWN). This prevents institutions from being asked for a "surname".

Form repopulation — complete
The new-person server action now echoes back personType, title, firstName, lastName, alias on validation failure. Users don't lose their input.

Minor: focus ring token mismatch
PersonTypeSelector uses focus-visible:ring-brand-navy while inputs in the same form use focus-visible:ring-focus-ring. Visually they may match depending on how the CSS variable resolves, but using a different token is semantically inconsistent. Suggest aligning to focus-visible:ring-focus-ring.

## 🎨 Leonie Voss — UI/UX Expert **Verdict: ✅ Approved** ### What I checked - ARIA patterns and keyboard accessibility - Touch target sizes - Visual hierarchy and typography - Form repopulation and error UX - Conditional field visibility ### Findings **Accessibility — strong implementation** - `role="radiogroup"` on the container + `role="radio"` on each button + `aria-checked` + roving tabindex (`tabindex={selected === type ? 0 : -1}`) — this is textbook WAI-ARIA 1.2 radio group pattern. - Arrow key navigation via `radioGroupNav` action handles wrap-around correctly in both directions. - `aria-live="polite"` announces type changes to screen readers without interrupting. The region only fires on changes, not on page load. ✅ **Touch targets — correct** `min-h-[48px]` on each button exceeds WCAG 2.5.5 (44px). ✅ **Title typography — nice** `[font-variant:small-caps]` for honorifics above the name in PersonCard is the right visual treatment for honorific prefixes like "Dr." or "Prof.". Gated correctly on `personType === 'PERSON' && person.title`. ✅ **Adaptive labels — correct** `lastNameLabel` switches between "Name" (INSTITUTION/GROUP) and "Nachname" (PERSON/UNKNOWN). This prevents institutions from being asked for a "surname". ✅ **Form repopulation — complete** The new-person server action now echoes back `personType`, `title`, `firstName`, `lastName`, `alias` on validation failure. Users don't lose their input. ✅ **Minor: focus ring token mismatch** `PersonTypeSelector` uses `focus-visible:ring-brand-navy` while inputs in the same form use `focus-visible:ring-focus-ring`. Visually they may match depending on how the CSS variable resolves, but using a different token is semantically inconsistent. Suggest aligning to `focus-visible:ring-focus-ring`.
Author
Owner

⚙️ Tobias Wendt — DevOps / Infrastructure

Verdict: Approved

What I checked

  • Schema/migration impact
  • Breaking changes for existing integrations
  • Docker/infra surface
  • Deployment risk

Findings

No migration needed
title and personType columns already exist on the persons table. No V{n}__*.sql migration required.

Breaking API change — check before deploying
@NotNull on PersonUpdateDTO.personType means any existing caller that omits personType from a POST /api/persons or PUT /api/persons/{id} body will receive a 400 after this deploys. Key callers to verify:

  • MassImportService — calls the three-argument createPerson(String, String, String) overload, not the DTO variant. Safe.
  • API test files in backend/api_tests/ — may need personType added to request bodies.
  • Any external scripts that hit the API directly.

FTS fix is low-risk
to_tsquery('simple', ...) relaxes the query builder to avoid German stop-word collisions. The DocumentFtsTest regression test confirms correctness. No index rebuild needed — simple and german use the same underlying GIN index.

No infra surface changes
Docker Compose, MinIO config, Nginx/reverse proxy config — all untouched. Standard ./mvnw clean package && docker-compose up -d deployment applies.

## ⚙️ Tobias Wendt — DevOps / Infrastructure **Verdict: ✅ Approved** ### What I checked - Schema/migration impact - Breaking changes for existing integrations - Docker/infra surface - Deployment risk ### Findings **No migration needed** `title` and `personType` columns already exist on the `persons` table. No `V{n}__*.sql` migration required. ✅ **Breaking API change — check before deploying** `@NotNull` on `PersonUpdateDTO.personType` means any existing caller that omits `personType` from a `POST /api/persons` or `PUT /api/persons/{id}` body will receive a 400 after this deploys. Key callers to verify: - `MassImportService` — calls the three-argument `createPerson(String, String, String)` overload, not the DTO variant. ✅ Safe. - API test files in `backend/api_tests/` — may need `personType` added to request bodies. - Any external scripts that hit the API directly. **FTS fix is low-risk** `to_tsquery('simple', ...)` relaxes the query builder to avoid German stop-word collisions. The `DocumentFtsTest` regression test confirms correctness. No index rebuild needed — `simple` and `german` use the same underlying GIN index. **No infra surface changes** Docker Compose, MinIO config, Nginx/reverse proxy config — all untouched. Standard `./mvnw clean package && docker-compose up -d` deployment applies.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Issue #218 checklist

Requirement Status
Surface personType in new-person form PersonTypeSelector added to /persons/new
Surface personType in edit form PersonTypeSelector added to PersonEditForm
Surface title in new-person form (PERSON only) Conditional on isPerson
Surface title in edit form (PERSON only) Conditional on isPerson
Show title in PersonCard (PERSON only) Rendered above displayName with small-caps
Normalize SKIP → UNKNOWN in edit load Done in +page.server.ts load function
INSTITUTION/GROUP hide personal fields title, firstName, alias, birthYear, deathYear all hidden
Backend: persist personType on create/update PersonService sets personType from DTO
Backend: persist title on create/update PersonService sets title from DTO
i18n (de/en/es) All 4 new keys in all 3 locales

Observations

1. FTS fix is out of scope
The DocumentRepository to_tsquery('simple') fix addresses a search correctness bug unrelated to issue #218. It's a good fix, but it's not mentioned in the PR description, which makes it invisible in the issue's change history. Recommend noting it explicitly in the PR description or linking to a separate issue so it's traceable.

2. UNKNOWN type behavior
The spec §4 matrix treats UNKNOWN the same as PERSON for the lastName label ("Nachname"), but the same as non-PERSON for hiding title/firstName/alias/birth/death fields. The implementation matches this: lastNameLabel uses "Nachname" for UNKNOWN, and isPerson is false for UNKNOWN (so fields are hidden).

3. Backend title persistence on update
The PersonService.updatePerson now sets person.setTitle(dto.getTitle()) — this means sending a null title will clear an existing one. This is the correct behavior for a PUT (full replacement), but worth confirming with the product owner that title erasure is intentional rather than a no-op.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** ### Issue #218 checklist | Requirement | Status | |---|---| | Surface `personType` in new-person form | ✅ PersonTypeSelector added to `/persons/new` | | Surface `personType` in edit form | ✅ PersonTypeSelector added to `PersonEditForm` | | Surface `title` in new-person form (PERSON only) | ✅ Conditional on `isPerson` | | Surface `title` in edit form (PERSON only) | ✅ Conditional on `isPerson` | | Show `title` in PersonCard (PERSON only) | ✅ Rendered above displayName with small-caps | | Normalize SKIP → UNKNOWN in edit load | ✅ Done in `+page.server.ts` load function | | INSTITUTION/GROUP hide personal fields | ✅ `title`, `firstName`, `alias`, `birthYear`, `deathYear` all hidden | | Backend: persist personType on create/update | ✅ `PersonService` sets `personType` from DTO | | Backend: persist title on create/update | ✅ `PersonService` sets `title` from DTO | | i18n (de/en/es) | ✅ All 4 new keys in all 3 locales | ### Observations **1. FTS fix is out of scope** The `DocumentRepository` `to_tsquery('simple')` fix addresses a search correctness bug unrelated to issue #218. It's a good fix, but it's not mentioned in the PR description, which makes it invisible in the issue's change history. Recommend noting it explicitly in the PR description or linking to a separate issue so it's traceable. **2. UNKNOWN type behavior** The spec §4 matrix treats UNKNOWN the same as PERSON for the `lastName` label ("Nachname"), but the same as non-PERSON for hiding `title`/`firstName`/`alias`/birth/death fields. The implementation matches this: `lastNameLabel` uses "Nachname" for UNKNOWN, and `isPerson` is false for UNKNOWN (so fields are hidden). ✅ **3. Backend `title` persistence on update** The `PersonService.updatePerson` now sets `person.setTitle(dto.getTitle())` — this means sending a null title will clear an existing one. This is the correct behavior for a PUT (full replacement), but worth confirming with the product owner that title erasure is intentional rather than a no-op.
Author
Owner

Dark mode fix applied

Concern addressed: PersonTypeSelector buttons did not adapt to dark mode — hardcoded brand-navy/brand-sand/white classes are invisible on dark surfaces.

What changedd6f91d66 fix(persons): use semantic color tokens in PersonTypeSelector for dark mode

State Before After
Selected bg-brand-navy text-white border-brand-navy bg-primary text-primary-fg border-primary
Unselected bg-white text-brand-navy border-brand-sand bg-surface text-ink border-line
Hover hover:border-brand-navy/50 hover:border-primary/50
Focus ring ring-brand-navy ring-focus-ring

In dark mode bg-primary resolves to mint (#a1dcd8) with navy foreground — the same swap the rest of the UI already uses. All other components introduced in this PR (PersonCard, PersonEditForm, PersonEditSaveBar, new-person form) were already using semantic tokens correctly.

Tests added: PersonTypeSelector.svelte.spec.ts — 3 browser-mode assertions verifying bg-primary, bg-surface/text-ink/border-line, and ring-focus-ring are present in the rendered DOM. All 3 were red before the fix, green after.

## Dark mode fix applied ✅ **Concern addressed:** `PersonTypeSelector` buttons did not adapt to dark mode — hardcoded `brand-navy`/`brand-sand`/`white` classes are invisible on dark surfaces. **What changed** — `d6f91d66` `fix(persons): use semantic color tokens in PersonTypeSelector for dark mode` | State | Before | After | |---|---|---| | Selected | `bg-brand-navy text-white border-brand-navy` | `bg-primary text-primary-fg border-primary` | | Unselected | `bg-white text-brand-navy border-brand-sand` | `bg-surface text-ink border-line` | | Hover | `hover:border-brand-navy/50` | `hover:border-primary/50` | | Focus ring | `ring-brand-navy` | `ring-focus-ring` | In dark mode `bg-primary` resolves to mint (`#a1dcd8`) with navy foreground — the same swap the rest of the UI already uses. All other components introduced in this PR (`PersonCard`, `PersonEditForm`, `PersonEditSaveBar`, new-person form) were already using semantic tokens correctly. **Tests added:** `PersonTypeSelector.svelte.spec.ts` — 3 browser-mode assertions verifying `bg-primary`, `bg-surface`/`text-ink`/`border-line`, and `ring-focus-ring` are present in the rendered DOM. All 3 were red before the fix, green after.
Author
Owner

Review concerns addressed

All open reviewer concerns have been resolved in 3 commits on this branch.


Nora #3title not trimmed server-side

Commit: fe981336fix(persons): trim title server-side and add SKIP controller test

PersonController.createPerson and updatePerson now call dto.setTitle(dto.getTitle().trim()) when title is non-null, consistent with the existing firstName/lastName trim pattern. Covered by a new ArgumentCaptor-based test that verifies the trimmed value reaches the service.


Sara Missing #2 — No HTTP-level SKIP rejection test

Commit: fe981336 — same commit

Added PersonControllerTest.createPerson_returns400_whenPersonTypeIsSkip: sends personType: "SKIP" via MockMvc, mocks the service to throw DomainException.badRequest(INVALID_PERSON_TYPE, ...), and asserts the controller returns 400. Confirms full controller → GlobalExceptionHandler error propagation for SKIP.


Felix / Sara Blocker #1normalizePersonType tests bound to local copy, not production code

Commit: 84986e7erefactor(persons): export normalizePersonType from edit server module

Extracted the inline ternary in +page.server.ts load as a named exported function. Updated page.server.test.ts to import { normalizePersonType } from './+page.server' — the 6 tests now bind directly to production code. If the inline logic ever changes, the tests will catch it.


Sara Missing #3 — Server action validation untested in both edit and new forms

Commit: 8295ca83test(persons): extract validatePersonFields and cover validation branches

  • New src/lib/person-validation.ts exports validatePersonFields(personType, firstName, lastName): string | null
  • Both edit/+page.server.ts and new/+page.server.ts now call this shared helper instead of duplicated inline if-chains
  • src/lib/person-validation.test.ts — 8 unit tests covering: valid PERSON, lastName missing, lastName undefined, firstName missing for PERSON, firstName undefined for PERSON, INSTITUTION/GROUP/UNKNOWN without firstName (all valid)

Test results after all changes

  • Backend: 1355/1355 (./mvnw test)
  • Frontend server: 385/385 (npm run test --project=server)
  • Backend build: (./mvnw clean package -DskipTests)

Not actioned

  • Markus #2 (SKIP guard before findById — minor semantic inversion): no code change requested, noted as advisory only
  • API test files (backend/api_tests/): no Person.http file exists in the directory; no update required
## Review concerns addressed ✅ All open reviewer concerns have been resolved in 3 commits on this branch. --- ### ✅ Nora #3 — `title` not trimmed server-side **Commit:** `fe981336` — `fix(persons): trim title server-side and add SKIP controller test` `PersonController.createPerson` and `updatePerson` now call `dto.setTitle(dto.getTitle().trim())` when title is non-null, consistent with the existing `firstName`/`lastName` trim pattern. Covered by a new `ArgumentCaptor`-based test that verifies the trimmed value reaches the service. --- ### ✅ Sara Missing #2 — No HTTP-level SKIP rejection test **Commit:** `fe981336` — same commit Added `PersonControllerTest.createPerson_returns400_whenPersonTypeIsSkip`: sends `personType: "SKIP"` via `MockMvc`, mocks the service to throw `DomainException.badRequest(INVALID_PERSON_TYPE, ...)`, and asserts the controller returns 400. Confirms full controller → `GlobalExceptionHandler` error propagation for SKIP. --- ### ✅ Felix / Sara Blocker #1 — `normalizePersonType` tests bound to local copy, not production code **Commit:** `84986e7e` — `refactor(persons): export normalizePersonType from edit server module` Extracted the inline ternary in `+page.server.ts` load as a named exported function. Updated `page.server.test.ts` to `import { normalizePersonType } from './+page.server'` — the 6 tests now bind directly to production code. If the inline logic ever changes, the tests will catch it. --- ### ✅ Sara Missing #3 — Server action validation untested in both edit and new forms **Commit:** `8295ca83` — `test(persons): extract validatePersonFields and cover validation branches` - New `src/lib/person-validation.ts` exports `validatePersonFields(personType, firstName, lastName): string | null` - Both `edit/+page.server.ts` and `new/+page.server.ts` now call this shared helper instead of duplicated inline `if`-chains - `src/lib/person-validation.test.ts` — 8 unit tests covering: valid PERSON, `lastName` missing, `lastName` undefined, `firstName` missing for PERSON, `firstName` undefined for PERSON, INSTITUTION/GROUP/UNKNOWN without firstName (all valid) --- ### Test results after all changes - **Backend:** 1355/1355 ✅ (`./mvnw test`) - **Frontend server:** 385/385 ✅ (`npm run test --project=server`) - **Backend build:** ✅ (`./mvnw clean package -DskipTests`) ### Not actioned - **Markus #2** (SKIP guard before `findById` — minor semantic inversion): no code change requested, noted as advisory only - **API test files** (`backend/api_tests/`): no `Person.http` file exists in the directory; no update required
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

1. Keyboard navigation doesn't update form state — silent wrong-value submission

radioGroupNav.ts manipulates aria-checked directly on the DOM, but PersonTypeSelector.svelte uses a reactive $state<PersonType> variable called selected which drives the hidden <input type="hidden" name={name} value={selected} />. When the user navigates with ArrowLeft/ArrowRight, the DOM shows the new button as checked, but selected is never updated — so the form submits the value that was last set by a click, not the keyboard-navigated value.

The fix: radioGroupNav needs to dispatch a custom DOM event (e.g. radiogroup-change) that PersonTypeSelector listens to and calls select(type) with. Or the action needs a onchange callback parameter.

// radioGroupNav.ts — current (broken)
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();

// radioGroupNav.ts — fixed
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
node.dispatchEvent(new CustomEvent('radiogroup-change', { detail: radios[next].value, bubbles: true }));

The 7 action tests pass because they only assert DOM state, not reactive state integration. This is a test coverage gap too (Sara will expand on this).


Suggestions

2. PersonType union type defined in four placesPersonTypeSelector.svelte, PersonEditForm.svelte, new/+page.svelte, and edit/+page.server.ts all independently declare type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN'. Extract to $lib/types/person.ts or export from PersonTypeSelector.svelte. When a new type is added, four files need to change.

3. TYPES as const also duplicated — same array declared in PersonTypeSelector.svelte, PersonEditForm.svelte, and new/+page.svelte. Import from PersonTypeSelector or a shared module.

4. Comment in PersonCard.svelte<!-- Title (academic/honorific) — PERSON type only --> explains what the code does, not why. The conditional {#if person.personType === 'PERSON' && person.title} already reads like the comment. Per clean code rules: if the code needs a "what" comment, the code should speak for itself; if there's a non-obvious why, explain that instead. Remove it.

5. to_tsquery('simple', ...) bugfix in DocumentRepository — this is a correct fix with a clear test (should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word) but it is a separate concern from person type + title. It should have been its own atomic commit and possibly its own issue. Bundling it here makes the commit history harder to bisect. Not a blocker, but worth noting.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers **1. Keyboard navigation doesn't update form state — silent wrong-value submission** `radioGroupNav.ts` manipulates `aria-checked` directly on the DOM, but `PersonTypeSelector.svelte` uses a reactive `$state<PersonType>` variable called `selected` which drives the hidden `<input type="hidden" name={name} value={selected} />`. When the user navigates with ArrowLeft/ArrowRight, the DOM shows the new button as checked, but `selected` is never updated — so the form submits the value that was last set by a click, not the keyboard-navigated value. The fix: `radioGroupNav` needs to dispatch a custom DOM event (e.g. `radiogroup-change`) that `PersonTypeSelector` listens to and calls `select(type)` with. Or the action needs a `onchange` callback parameter. ```typescript // radioGroupNav.ts — current (broken) radios[next].setAttribute('aria-checked', 'true'); radios[next].focus(); // radioGroupNav.ts — fixed radios[next].setAttribute('aria-checked', 'true'); radios[next].focus(); node.dispatchEvent(new CustomEvent('radiogroup-change', { detail: radios[next].value, bubbles: true })); ``` The 7 action tests pass because they only assert DOM state, not reactive state integration. This is a test coverage gap too (Sara will expand on this). --- ### Suggestions **2. `PersonType` union type defined in four places** — `PersonTypeSelector.svelte`, `PersonEditForm.svelte`, `new/+page.svelte`, and `edit/+page.server.ts` all independently declare `type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN'`. Extract to `$lib/types/person.ts` or export from `PersonTypeSelector.svelte`. When a new type is added, four files need to change. **3. `TYPES as const` also duplicated** — same array declared in `PersonTypeSelector.svelte`, `PersonEditForm.svelte`, and `new/+page.svelte`. Import from `PersonTypeSelector` or a shared module. **4. Comment in PersonCard.svelte** — `<!-- Title (academic/honorific) — PERSON type only -->` explains what the code does, not why. The conditional `{#if person.personType === 'PERSON' && person.title}` already reads like the comment. Per clean code rules: if the code needs a "what" comment, the code should speak for itself; if there's a non-obvious *why*, explain that instead. Remove it. **5. `to_tsquery('simple', ...)` bugfix in `DocumentRepository`** — this is a correct fix with a clear test (`should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word`) but it is a separate concern from person type + title. It should have been its own atomic commit and possibly its own issue. Bundling it here makes the commit history harder to bisect. Not a blocker, but worth noting.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No security vulnerabilities identified. Full audit summary:

What I checked

Input validation & injection surface

  • @NotNull PersonType personType on the DTO means Jackson deserialization rejects unknown string values with a 400 before business logic runs — no string-to-enum injection possible.
  • @Size(max=50) String title is enforced by Bean Validation before the service layer.
  • personType being an enum in the DTO means the type system guarantees only valid enum values reach PersonService.

SKIP guard placement

  • The SKIP check fires at the service layer (PersonService.createPerson / updatePerson), which is the correct defence-in-depth position: even if the controller boundary is bypassed, the service still rejects it.

Authorization

  • Both createPerson and updatePerson retain @RequirePermission(Permission.WRITE_ALL). The SKIP guard does not accidentally change who can call these endpoints.

Error information exposure

  • DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, ...) returns a structured error code. The frontend maps this to error_invalid_person_type i18n key via getErrorMessage() — no implementation detail leaks to the user.

Suggestion (not a blocker)

validatePersonNames() in PersonController throws ResponseStatusException (CLAUDE.md notes this is acceptable for simple controller validation), but this bypasses the DomainExceptionErrorCode pipeline. The frontend cannot map these messages to localized strings — the message "Nachname ist Pflichtfeld" / "Vorname ist Pflichtfeld" appears raw in HTTP responses. This is not a security issue but a localization leak worth addressing (Elicit's review will cover this from a requirements angle).

CWE summary: No findings. CWE-20 (improper input validation) addressed. CWE-285 (improper authorization) unchanged/correct.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No security vulnerabilities identified. Full audit summary: ### What I checked **Input validation & injection surface** - `@NotNull PersonType personType` on the DTO means Jackson deserialization rejects unknown string values with a 400 before business logic runs — no string-to-enum injection possible. ✅ - `@Size(max=50) String title` is enforced by Bean Validation before the service layer. ✅ - `personType` being an enum in the DTO means the type system guarantees only valid enum values reach `PersonService`. ✅ **SKIP guard placement** - The SKIP check fires at the service layer (`PersonService.createPerson` / `updatePerson`), which is the correct defence-in-depth position: even if the controller boundary is bypassed, the service still rejects it. ✅ **Authorization** - Both `createPerson` and `updatePerson` retain `@RequirePermission(Permission.WRITE_ALL)`. The SKIP guard does not accidentally change who can call these endpoints. ✅ **Error information exposure** - `DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, ...)` returns a structured error code. The frontend maps this to `error_invalid_person_type` i18n key via `getErrorMessage()` — no implementation detail leaks to the user. ✅ ### Suggestion (not a blocker) `validatePersonNames()` in `PersonController` throws `ResponseStatusException` (CLAUDE.md notes this is acceptable for simple controller validation), but this bypasses the `DomainException` → `ErrorCode` pipeline. The frontend cannot map these messages to localized strings — the message `"Nachname ist Pflichtfeld"` / `"Vorname ist Pflichtfeld"` appears raw in HTTP responses. This is not a security issue but a localization leak worth addressing (Elicit's review will cover this from a requirements angle). **CWE summary:** No findings. CWE-20 (improper input validation) addressed. CWE-285 (improper authorization) unchanged/correct.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

No structural blockers

Layer boundaries are correct throughout: controller validates → service persists → repository is never crossed from the wrong domain. PersonService owns its repository; PersonController delegates cleanly. validatePersonNames() in the controller is appropriate controller-boundary validation.

Concerns

1. normalizePersonType is in the wrong module

normalizePersonType is exported from frontend/src/routes/persons/[id]/edit/+page.server.ts. This function is presentation/domain logic (mapping an import-only enum value to a safe default) and already has a sibling in person-validation.ts which is the right home for it. Exporting a utility function from a route server module couples the tests (page.server.test.ts imports from a route) to the route location. When the route moves, the tests break.

Move it to $lib/person-validation.ts where it belongs alongside validatePersonFields.

2. PersonType union type fragmented across 4 files

type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN' is declared independently in:

  • PersonTypeSelector.svelte
  • PersonEditForm.svelte
  • new/+page.svelte
  • edit/+page.server.ts

The authoritative set of user-facing types should be one exported constant/type. Adding FAMILY or renaming GROUP in the future will require hunting through 4 files.

3. Unrelated FTS bugfix bundled into this PR

DocumentRepository.java has the to_tsquery('german', ...)to_tsquery('simple', ...) change for prefix queries. This is a correct fix (proven by the DocumentFtsTest regression test) but it belongs in its own commit and ideally its own issue/PR. A developer bisecting a person-type regression would have to mentally filter this out. Atomic commits exist precisely to prevent this.

What's done well

  • DB schema uses an enum column (person_type) with a CHECK constraint enforced at the PostgreSQL level — application code can't write arbitrary strings to it.
  • Monolith boundaries intact — no new cross-service calls introduced.
  • @Transactional only on write methods.
## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** ### No structural blockers Layer boundaries are correct throughout: controller validates → service persists → repository is never crossed from the wrong domain. `PersonService` owns its repository; `PersonController` delegates cleanly. `validatePersonNames()` in the controller is appropriate controller-boundary validation. ✅ ### Concerns **1. `normalizePersonType` is in the wrong module** `normalizePersonType` is exported from `frontend/src/routes/persons/[id]/edit/+page.server.ts`. This function is presentation/domain logic (mapping an import-only enum value to a safe default) and already has a sibling in `person-validation.ts` which is the right home for it. Exporting a utility function from a route server module couples the tests (`page.server.test.ts` imports from a route) to the route location. When the route moves, the tests break. Move it to `$lib/person-validation.ts` where it belongs alongside `validatePersonFields`. **2. `PersonType` union type fragmented across 4 files** `type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN'` is declared independently in: - `PersonTypeSelector.svelte` - `PersonEditForm.svelte` - `new/+page.svelte` - `edit/+page.server.ts` The authoritative set of user-facing types should be one exported constant/type. Adding `FAMILY` or renaming `GROUP` in the future will require hunting through 4 files. **3. Unrelated FTS bugfix bundled into this PR** `DocumentRepository.java` has the `to_tsquery('german', ...)` → `to_tsquery('simple', ...)` change for prefix queries. This is a correct fix (proven by the `DocumentFtsTest` regression test) but it belongs in its own commit and ideally its own issue/PR. A developer bisecting a person-type regression would have to mentally filter this out. Atomic commits exist precisely to prevent this. ### What's done well - DB schema uses an enum column (`person_type`) with a CHECK constraint enforced at the PostgreSQL level — application code can't write arbitrary strings to it. ✅ - Monolith boundaries intact — no new cross-service calls introduced. ✅ - `@Transactional` only on write methods. ✅
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

Test coverage is genuinely good here — multiple layers covered, factory patterns used correctly, test names read as sentences. One blocker: the keyboard navigation bug Felix identified is undetected by tests.

Blockers

1. PersonTypeSelector has no behavioral test for hidden-input value after keyboard navigation

The three PersonTypeSelector.svelte.spec.ts tests check CSS classes on buttons — not that the hidden <input name="personType"> value reflects what the user navigated to with the keyboard. The radioGroupNav action mutates aria-checked directly without calling select(), so the hidden input keeps its old value. A test that:

  1. Renders the component
  2. Dispatches ArrowRight on the active button
  3. Asserts container.querySelector('input[type="hidden"]').value === 'INSTITUTION'

...would fail today and prove the bug. Add this test before merging.

Suggestions

2. PersonTypeSelector.svelte.spec.ts — CSS class tests are fragile assertions

Tests like expect(selected!.className).toContain('bg-primary') are snapshot-class-name tests. They'll fail on any Tailwind class rename or theme token change. What matters is that the selected button is visually distinguishable and the form value is correct. Consider testing:

  • aria-checked attribute values (behavior)
  • hidden input value after onclick (behavior)
  • tabindex values (keyboard accessibility)

3. createPerson_returns400_whenPersonTypeIsSkip in PersonControllerTest

This test configures the mock to throw INVALID_PERSON_TYPE and then checks the controller returns 400. It's testing mockito plumbing, not actual controller behavior — the SKIP guard lives in the service, so the controller has nothing to do here. The meaningful test is PersonServiceTest.createPerson_dto_throwsInvalidPersonType_whenSkip. The controller test adds noise. Remove or replace with a test that verifies the 400 response shape (errorCode field).

4. person-validation.test.ts asserts on hardcoded German strings

expect(validatePersonFields('PERSON', 'Hans', '')).toBe('Nachname ist Pflichtfeld.');

If the error message text changes, every assertion in this file breaks. Extract the expected strings to a constant or test the return type / key instead. This also reveals the localization bug (Elicit will detail it).

5. No test for type-change behavior during edit form submission

There's no test for the edit action when personType changes from INSTITUTION to PERSON — specifically that firstName validation is now enforced where it wasn't before. A unit test for the update action covering this transition would be valuable.

What's done well

  • Backend: 1353 tests green. All existing controller tests updated to pass personType, which proves existing tests still exercise the right behaviors.
  • createPerson_trimsTitle_beforePersisting with ArgumentCaptor — this is exactly the right way to test controller-level trimming.
  • DocumentFtsTest.should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word — regression test exists before the production fix. Correct TDD order.
  • page.server.test.ts tests all 5 pass-through cases plus null — complete boundary coverage for normalizePersonType.
## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** Test coverage is genuinely good here — multiple layers covered, factory patterns used correctly, test names read as sentences. One blocker: the keyboard navigation bug Felix identified is undetected by tests. ### Blockers **1. `PersonTypeSelector` has no behavioral test for hidden-input value after keyboard navigation** The three `PersonTypeSelector.svelte.spec.ts` tests check CSS classes on buttons — not that the hidden `<input name="personType">` value reflects what the user navigated to with the keyboard. The `radioGroupNav` action mutates `aria-checked` directly without calling `select()`, so the hidden input keeps its old value. A test that: 1. Renders the component 2. Dispatches `ArrowRight` on the active button 3. Asserts `container.querySelector('input[type="hidden"]').value === 'INSTITUTION'` ...would fail today and prove the bug. Add this test before merging. ### Suggestions **2. `PersonTypeSelector.svelte.spec.ts` — CSS class tests are fragile assertions** Tests like `expect(selected!.className).toContain('bg-primary')` are snapshot-class-name tests. They'll fail on any Tailwind class rename or theme token change. What matters is that the selected button is _visually distinguishable_ and the form value is correct. Consider testing: - `aria-checked` attribute values (behavior) - hidden input value after `onclick` (behavior) - `tabindex` values (keyboard accessibility) **3. `createPerson_returns400_whenPersonTypeIsSkip` in `PersonControllerTest`** This test configures the mock to throw `INVALID_PERSON_TYPE` and then checks the controller returns 400. It's testing mockito plumbing, not actual controller behavior — the SKIP guard lives in the service, so the controller has nothing to do here. The meaningful test is `PersonServiceTest.createPerson_dto_throwsInvalidPersonType_whenSkip`. The controller test adds noise. Remove or replace with a test that verifies the 400 response shape (errorCode field). **4. `person-validation.test.ts` asserts on hardcoded German strings** ```typescript expect(validatePersonFields('PERSON', 'Hans', '')).toBe('Nachname ist Pflichtfeld.'); ``` If the error message text changes, every assertion in this file breaks. Extract the expected strings to a constant or test the return type / key instead. This also reveals the localization bug (Elicit will detail it). **5. No test for type-change behavior during edit form submission** There's no test for the edit action when `personType` changes from `INSTITUTION` to `PERSON` — specifically that firstName validation is now enforced where it wasn't before. A unit test for the `update` action covering this transition would be valuable. ### What's done well - Backend: 1353 tests green. All existing controller tests updated to pass `personType`, which proves existing tests still exercise the right behaviors. ✅ - `createPerson_trimsTitle_beforePersisting` with `ArgumentCaptor` — this is exactly the right way to test controller-level trimming. ✅ - `DocumentFtsTest.should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word` — regression test exists before the production fix. Correct TDD order. ✅ - `page.server.test.ts` tests all 5 pass-through cases plus null — complete boundary coverage for `normalizePersonType`. ✅
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

The PersonTypeSelector is well-built from a UI/UX foundation: ARIA roles, keyboard navigation, focus rings, and touch targets all meet or exceed requirements. Two concerns worth addressing.

Concerns

1. aria-live region announces on initial page load

<div class="sr-only" aria-live="polite">
    {m.a11y_type_changed({ type: labels[selected]() })}
</div>

This region always contains the current type label text. Most screen readers don't announce the initial content of an aria-live region, but some (NVDA + Firefox) do on focus entry to the region. More importantly: this renders "Typ geändert zu Person" immediately on page load, which is confusing — the user hasn't changed anything yet. The announcement should only fire on actual user-initiated change.

Fix: use an aria-atomic="true" region that starts empty and is populated only after the first user interaction:

let announcement = $state('');

function select(type: PersonType) {
    selected = type;
    announcement = m.a11y_type_changed({ type: labels[type]() });
    onchange?.(type);
}
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>

2. Verify text-ink-3 token exists in layout.css

PersonCard.svelte uses text-ink-3 for the title text and PersonTypeSelector.svelte uses it for the type label. If this token is not defined in layout.css, it will fall back silently to color: inherit in Tailwind v4, making the text indistinguishable from the surrounding content in dark mode. Check that --color-ink-3 is in the design token set with sufficient contrast against bg-white (≥4.5:1 for body text).

What's done well

  • min-h-[48px] on all radio buttons — exceeds the 44px touch target minimum for the 60+ audience.
  • focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none — correct focus-visible usage, invisible on mouse click, visible on keyboard.
  • Mobile-first grid: grid-cols-2 sm:grid-cols-4 — 2×2 on small screens, single row on desktop. Correct approach.
  • [font-variant:small-caps] for the title in PersonCard — typographically appropriate for honorifics, consistent with the serif archival tone.
  • role="radiogroup" + role="radio" + aria-checked — correct ARIA pattern for a segmented control.
  • The lastNameLabel derived value ("Name" for INSTITUTION/GROUP, "Nachname" for PERSON/UNKNOWN) is a small but meaningful UX detail that matches how these entities are actually named in real use.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** The PersonTypeSelector is well-built from a UI/UX foundation: ARIA roles, keyboard navigation, focus rings, and touch targets all meet or exceed requirements. Two concerns worth addressing. ### Concerns **1. `aria-live` region announces on initial page load** ```svelte <div class="sr-only" aria-live="polite"> {m.a11y_type_changed({ type: labels[selected]() })} </div> ``` This region always contains the current type label text. Most screen readers don't announce the initial content of an `aria-live` region, but some (NVDA + Firefox) do on focus entry to the region. More importantly: this renders `"Typ geändert zu Person"` immediately on page load, which is confusing — the user hasn't changed anything yet. The announcement should only fire on actual user-initiated change. Fix: use an `aria-atomic="true"` region that starts empty and is populated only after the first user interaction: ```svelte let announcement = $state(''); function select(type: PersonType) { selected = type; announcement = m.a11y_type_changed({ type: labels[type]() }); onchange?.(type); } ``` ```svelte <div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div> ``` **2. Verify `text-ink-3` token exists in `layout.css`** `PersonCard.svelte` uses `text-ink-3` for the title text and `PersonTypeSelector.svelte` uses it for the type label. If this token is not defined in `layout.css`, it will fall back silently to `color: inherit` in Tailwind v4, making the text indistinguishable from the surrounding content in dark mode. Check that `--color-ink-3` is in the design token set with sufficient contrast against `bg-white` (≥4.5:1 for body text). ### What's done well - `min-h-[48px]` on all radio buttons — exceeds the 44px touch target minimum for the 60+ audience. ✅ - `focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none` — correct `focus-visible` usage, invisible on mouse click, visible on keyboard. ✅ - Mobile-first grid: `grid-cols-2 sm:grid-cols-4` — 2×2 on small screens, single row on desktop. Correct approach. ✅ - `[font-variant:small-caps]` for the title in `PersonCard` — typographically appropriate for honorifics, consistent with the serif archival tone. ✅ - `role="radiogroup"` + `role="radio"` + `aria-checked` — correct ARIA pattern for a segmented control. ✅ - The `lastNameLabel` derived value (`"Name"` for INSTITUTION/GROUP, `"Nachname"` for PERSON/UNKNOWN) is a small but meaningful UX detail that matches how these entities are actually named in real use. ✅
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure-related changes in this PR. LGTM from a platform perspective.

What I checked

  • No new Flyway migration required: personType and title fields on Person already have database columns from a prior migration. This PR only adds them to the DTO and persists them via the service layer.
  • to_tsquery('simple', ...) change: This is a query change, not a schema change. No migration needed. The existing FTS indexes cover the simple dictionary.
  • No Docker Compose changes: Service topology unchanged.
  • No CI workflow changes: Pipeline configuration untouched.
  • Backend: 1353/1353 tests green: All Flyway migrations pass the Testcontainers suite. Schema is consistent.
  • No new environment variables: No config changes needed in .env or application profiles.

One note for future work: when PersonType enum values are added or removed, the PostgreSQL CHECK constraint on the person_type column will need a matching Flyway migration (constraint update). This is not an issue in this PR but worth knowing for when FAMILY or similar types are eventually added.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure-related changes in this PR. LGTM from a platform perspective. ### What I checked - **No new Flyway migration required**: `personType` and `title` fields on `Person` already have database columns from a prior migration. This PR only adds them to the DTO and persists them via the service layer. ✅ - **`to_tsquery('simple', ...)` change**: This is a query change, not a schema change. No migration needed. The existing FTS indexes cover the `simple` dictionary. ✅ - **No Docker Compose changes**: Service topology unchanged. ✅ - **No CI workflow changes**: Pipeline configuration untouched. ✅ - **Backend: 1353/1353 tests green**: All Flyway migrations pass the Testcontainers suite. Schema is consistent. ✅ - **No new environment variables**: No config changes needed in `.env` or application profiles. ✅ One note for future work: when `PersonType` enum values are added or removed, the PostgreSQL CHECK constraint on the `person_type` column will need a matching Flyway migration (constraint update). This is not an issue in this PR but worth knowing for when `FAMILY` or similar types are eventually added.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

The implementation covers the core functional requirements for issue #218. One requirements gap that creates a localization defect.

Blocker

Validation error messages are not localized — German hardcoded in a multilingual app

person-validation.ts returns hardcoded German strings:

if (!lastName) return 'Nachname ist Pflichtfeld.';
if (personType === 'PERSON' && !firstName) return 'Vorname ist Pflichtfeld.';

These strings are returned from +page.server.ts actions inside fail(400, { error: validationError }) and rendered directly in the UI as form.error / form.updateError. The application supports three locales (de/en/es). An English-locale user who tries to save a PERSON without a first name will see "Vorname ist Pflichtfeld." — German, untranslated.

The fix is to return an error key rather than a message, and map it through Paraglide on the frontend, or to call m.xxx() from within the server action where the locale is available. Since person-validation.ts runs in server context where locale resolution is available via SvelteKit's locals, the messages could use the Paraglide server-side API.

Alternatively, return an error code key ('FIRST_NAME_REQUIRED', 'LAST_NAME_REQUIRED') and map it to localized strings in the component like the rest of the error handling does.

Requirements gaps from spec §4

UNKNOWN type label: When selectedType === 'UNKNOWN', the lastNameLabel resolves to m.form_label_last_name() (i.e., "Nachname"). For an unknown/unidentified person, "Nachname" may not be semantically accurate — the user might enter a description like "Unbekannter Mann auf Foto 1923". This is a UX judgment call worth reviewing with the original issue: does the spec define what the lastName field represents for UNKNOWN-type persons?

What's correctly covered

  • SKIP type rejected on create and update — matches the spec's import-only constraint.
  • SKIP→UNKNOWN normalization on edit load — prevents legacy SKIP persons from corrupting the form.
  • firstName only required for PERSON type — spec §4 matrix correctly implemented.
  • title field (max 50 chars, PERSON only in UI) — implementation matches spec.
  • PersonCard displays title in small-caps above displayName — spec §5 visual requirement met.
  • All three locales (de/en/es) updated with new i18n keys — complete.
## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** The implementation covers the core functional requirements for issue #218. One requirements gap that creates a localization defect. ### Blocker **Validation error messages are not localized — German hardcoded in a multilingual app** `person-validation.ts` returns hardcoded German strings: ```typescript if (!lastName) return 'Nachname ist Pflichtfeld.'; if (personType === 'PERSON' && !firstName) return 'Vorname ist Pflichtfeld.'; ``` These strings are returned from `+page.server.ts` actions inside `fail(400, { error: validationError })` and rendered directly in the UI as `form.error` / `form.updateError`. The application supports three locales (de/en/es). An English-locale user who tries to save a PERSON without a first name will see **"Vorname ist Pflichtfeld."** — German, untranslated. The fix is to return an error key rather than a message, and map it through Paraglide on the frontend, or to call `m.xxx()` from within the server action where the locale is available. Since `person-validation.ts` runs in server context where locale resolution is available via SvelteKit's `locals`, the messages could use the Paraglide server-side API. Alternatively, return an error code key (`'FIRST_NAME_REQUIRED'`, `'LAST_NAME_REQUIRED'`) and map it to localized strings in the component like the rest of the error handling does. ### Requirements gaps from spec §4 **UNKNOWN type label**: When `selectedType === 'UNKNOWN'`, the `lastNameLabel` resolves to `m.form_label_last_name()` (i.e., "Nachname"). For an unknown/unidentified person, "Nachname" may not be semantically accurate — the user might enter a description like "Unbekannter Mann auf Foto 1923". This is a UX judgment call worth reviewing with the original issue: does the spec define what the lastName field represents for UNKNOWN-type persons? ### What's correctly covered - SKIP type rejected on create and update — matches the spec's import-only constraint. ✅ - SKIP→UNKNOWN normalization on edit load — prevents legacy SKIP persons from corrupting the form. ✅ - firstName only required for PERSON type — spec §4 matrix correctly implemented. ✅ - `title` field (max 50 chars, PERSON only in UI) — implementation matches spec. ✅ - PersonCard displays title in small-caps above displayName — spec §5 visual requirement met. ✅ - All three locales (de/en/es) updated with new i18n keys — complete. ✅
marcel added 8 commits 2026-04-26 09:57:17 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(persons): extract validatePersonFields and cover validation branches
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m28s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 3m12s
CI / OCR Service Tests (pull_request) Successful in 50s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
0c47c22185
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
  firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
  of inline if-chains, making the logic testable and non-duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel force-pushed feat/issue-218-person-title-type-fields from 8295ca83af to 0c47c22185 2026-04-26 09:57:17 +02:00 Compare
marcel added 5 commits 2026-04-26 10:42:53 +02:00
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m16s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m23s
CI / Unit & Component Tests (pull_request) Failing after 3m25s
CI / OCR Service Tests (pull_request) Successful in 57s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
f5eb14a76d
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed

All open reviewer concerns from the second round have been resolved in 5 commits.


Felix/Sara Blocker #1 — Keyboard navigation doesn't update form state

Commit: 842ab28ffix(persons): keyboard navigation now updates PersonTypeSelector reactive state

radioGroupNav.ts now accepts an optional onChange callback (+ update/destroy lifecycle). PersonTypeSelector passes select() as the callback so ArrowLeft/ArrowRight navigation updates both selected state and the hidden <input> value. Added value={type} attribute to each radio button so the callback can read the navigated-to value. New test in PersonTypeSelector.svelte.spec.ts confirms: render with value='PERSON', dispatch ArrowRight, assert hidden input value is 'INSTITUTION'.


Leonie Concern #1aria-live region announces on initial page load

Commit: 842ab28f — same commit

announcement = $state('') starts empty. The select() function now sets announcement = m.a11y_type_changed(...) before calling onchange. The aria-live region renders {announcement} so it is silent on page load and announces only on user-initiated type changes. Added aria-atomic="true".


Markus Concern #1normalizePersonType in wrong module

Felix/Markus Concern #2PersonType union type in 4 files

Felix Suggestion #3TYPES array duplicated

Commit: 8e1733abrefactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation

$lib/person-validation.ts now exports PERSON_TYPES, PersonType, and normalizePersonType. All four consumer files (PersonTypeSelector.svelte, PersonEditForm.svelte, new/+page.svelte, edit/+page.server.ts) import from there. page.server.test.ts now imports normalizePersonType from $lib/person-validation instead of the route module. Both server actions now use normalizePersonType() for personType extraction instead of an inline type cast.


Elicit Blocker — Validation error messages hardcoded German strings

Sara Suggestion #4 — Tests assert on German strings

Commit: f9a982dbfix(persons): localize validation error messages via Paraglide i18n

validatePersonFields now returns PersonValidationKey | null ('validation_last_name_required' / 'validation_first_name_required'). resolveValidationMessage(key) in person-validation.ts translates via Paraglide. Both server actions call resolveValidationMessage() before passing to fail(). Added validation_last_name_required / validation_first_name_required to de.json, en.json, es.json. Tests now assert on keys, not German strings.


Felix Suggestion #4 — What-comment in PersonCard

Commit: 00af9765refactor(persons): remove what-comment from PersonCard title block

Removed <!-- Title (academic/honorific) — PERSON type only --> from line 70.


Sara Suggestion #3 — SKIP controller test only checks HTTP status

Commit: f5eb14a7test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip

Added andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE")) — now verifies the full GlobalExceptionHandler → structured error response pipeline, not just the HTTP status.


Not actioned

  • Leonie Concern #2 (text-ink-3 token) — verified: --color-ink-3 is defined in layout.css with 4.8:1 contrast (WCAG AA ✓). No change needed.
  • Nora suggestion (validatePersonNames in controller uses ResponseStatusException) — CLAUDE.md explicitly allows this for simple controller validation. Frontend form actions catch it before it reaches users in the UI.

Test results after all changes

  • Backend: 1354/1354 (./mvnw test)
  • Frontend server: 385/385 (vitest run --project=server)
  • Frontend client: 608/611 — 3 pre-existing failures in DocumentList, RichtlinienRuleCard, hilfe/transkription (unrelated to this PR, none in files touched by this branch)
## Review concerns addressed ✅ All open reviewer concerns from the second round have been resolved in 5 commits. --- ### ✅ Felix/Sara Blocker #1 — Keyboard navigation doesn't update form state **Commit:** `842ab28f` — `fix(persons): keyboard navigation now updates PersonTypeSelector reactive state` `radioGroupNav.ts` now accepts an optional `onChange` callback (+ `update`/`destroy` lifecycle). `PersonTypeSelector` passes `select()` as the callback so `ArrowLeft`/`ArrowRight` navigation updates both `selected` state and the hidden `<input>` value. Added `value={type}` attribute to each radio button so the callback can read the navigated-to value. New test in `PersonTypeSelector.svelte.spec.ts` confirms: render with `value='PERSON'`, dispatch `ArrowRight`, assert hidden input value is `'INSTITUTION'`. --- ### ✅ Leonie Concern #1 — `aria-live` region announces on initial page load **Commit:** `842ab28f` — same commit `announcement = $state('')` starts empty. The `select()` function now sets `announcement = m.a11y_type_changed(...)` before calling `onchange`. The `aria-live` region renders `{announcement}` so it is silent on page load and announces only on user-initiated type changes. Added `aria-atomic="true"`. --- ### ✅ Markus Concern #1 — `normalizePersonType` in wrong module ### ✅ Felix/Markus Concern #2 — `PersonType` union type in 4 files ### ✅ Felix Suggestion #3 — `TYPES` array duplicated **Commit:** `8e1733ab` — `refactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation` `$lib/person-validation.ts` now exports `PERSON_TYPES`, `PersonType`, and `normalizePersonType`. All four consumer files (`PersonTypeSelector.svelte`, `PersonEditForm.svelte`, `new/+page.svelte`, `edit/+page.server.ts`) import from there. `page.server.test.ts` now imports `normalizePersonType` from `$lib/person-validation` instead of the route module. Both server actions now use `normalizePersonType()` for `personType` extraction instead of an inline type cast. --- ### ✅ Elicit Blocker — Validation error messages hardcoded German strings ### ✅ Sara Suggestion #4 — Tests assert on German strings **Commit:** `f9a982db` — `fix(persons): localize validation error messages via Paraglide i18n` `validatePersonFields` now returns `PersonValidationKey | null` (`'validation_last_name_required'` / `'validation_first_name_required'`). `resolveValidationMessage(key)` in `person-validation.ts` translates via Paraglide. Both server actions call `resolveValidationMessage()` before passing to `fail()`. Added `validation_last_name_required` / `validation_first_name_required` to `de.json`, `en.json`, `es.json`. Tests now assert on keys, not German strings. --- ### ✅ Felix Suggestion #4 — What-comment in PersonCard **Commit:** `00af9765` — `refactor(persons): remove what-comment from PersonCard title block` Removed `<!-- Title (academic/honorific) — PERSON type only -->` from line 70. --- ### ✅ Sara Suggestion #3 — SKIP controller test only checks HTTP status **Commit:** `f5eb14a7` — `test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip` Added `andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"))` — now verifies the full `GlobalExceptionHandler` → structured error response pipeline, not just the HTTP status. --- ### Not actioned - **Leonie Concern #2** (`text-ink-3` token) — verified: `--color-ink-3` is defined in `layout.css` with 4.8:1 contrast (WCAG AA ✓). No change needed. - **Nora suggestion** (`validatePersonNames` in controller uses `ResponseStatusException`) — CLAUDE.md explicitly allows this for simple controller validation. Frontend form actions catch it before it reaches users in the UI. ### Test results after all changes - **Backend:** 1354/1354 ✅ (`./mvnw test`) - **Frontend server:** 385/385 ✅ (`vitest run --project=server`) - **Frontend client:** 608/611 — 3 pre-existing failures in `DocumentList`, `RichtlinienRuleCard`, `hilfe/transkription` (unrelated to this PR, none in files touched by this branch)
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

The implementation is clean and the key structural concerns from the previous review are fully resolved.

What's right

  • person-validation.ts as the single source of truth for PersonType, PERSON_TYPES, normalizePersonType, and validatePersonFields eliminates the type fragmentation that was a blocker. Every consumer now imports from one place.
  • The radioGroupNav callback pattern correctly solves the Svelte 5 custom event naming constraint. Returning { destroy, update } follows the Svelte action contract precisely.
  • validatePersonFields returning a typed PersonValidationKey instead of hardcoded German strings is the right separation of concerns — validation logic is decoupled from display.
  • $derived(selectedType === 'PERSON') for isPerson is idiomatic Svelte 5.
  • untrack() to initialize state from props prevents reactive cycles — appropriate use.
  • The keyboard navigation test (hiddenInput.value === 'INSTITUTION' after ArrowRight from PERSON) directly tests the bug that existed before — good regression anchor.

Suggestions (non-blocking)

  • new/+page.svelte extracts shared class strings to inputCls/labelCls constants, but PersonEditForm.svelte still inlines them. Minor inconsistency worth aligning in a follow-up.
  • Tests for CSS class names (bg-primary, text-primary-fg, bg-surface, etc.) are coupling to implementation details — a token rename would silently break them. Prefer asserting on aria-checked state or computed behavior over class name string matching.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** The implementation is clean and the key structural concerns from the previous review are fully resolved. ### What's right - `person-validation.ts` as the single source of truth for `PersonType`, `PERSON_TYPES`, `normalizePersonType`, and `validatePersonFields` eliminates the type fragmentation that was a blocker. Every consumer now imports from one place. - The `radioGroupNav` callback pattern correctly solves the Svelte 5 custom event naming constraint. Returning `{ destroy, update }` follows the Svelte action contract precisely. - `validatePersonFields` returning a typed `PersonValidationKey` instead of hardcoded German strings is the right separation of concerns — validation logic is decoupled from display. - `$derived(selectedType === 'PERSON')` for `isPerson` is idiomatic Svelte 5. - `untrack()` to initialize state from props prevents reactive cycles — appropriate use. - The keyboard navigation test (`hiddenInput.value === 'INSTITUTION'` after ArrowRight from PERSON) directly tests the bug that existed before — good regression anchor. ### Suggestions (non-blocking) - `new/+page.svelte` extracts shared class strings to `inputCls`/`labelCls` constants, but `PersonEditForm.svelte` still inlines them. Minor inconsistency worth aligning in a follow-up. - Tests for CSS class names (`bg-primary`, `text-primary-fg`, `bg-surface`, etc.) are coupling to implementation details — a token rename would silently break them. Prefer asserting on `aria-checked` state or computed behavior over class name string matching.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

Layer boundaries are maintained throughout. No architectural violations.

What's right

  • Controller → Service → Repository chain is intact. PersonController delegates to PersonService which owns the validation logic.
  • person-validation.ts is a pure shared module — no framework imports, no side effects, safe to import from anywhere in the frontend including server-side SvelteKit actions. Paraglide's m.xxx() calls are stateless; this is fine.
  • Extracting normalizePersonType from +page.server.ts to $lib/person-validation.ts breaks the test's dependency on a specific route file path — the tests now test the function in isolation, not indirectly through a route.
  • Both new/+page.server.ts and edit/+page.server.ts import from the same validation module — consistent cross-route behavior guaranteed.
  • The PersonEditForm is scoped to the edit context, +page.svelte handles the new-person context. The component boundary is reasonable.

Suggestions (non-blocking)

  • PersonEditForm.svelte receives a person prop with a large inline type annotation (personType, title, firstName, lastName, alias, birthYear, deathYear, notes). As this form grows, an explicit named type or interface would make prop contracts visible at the call site rather than requiring the reader to open the component.
## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** Layer boundaries are maintained throughout. No architectural violations. ### What's right - Controller → Service → Repository chain is intact. `PersonController` delegates to `PersonService` which owns the validation logic. ✅ - `person-validation.ts` is a pure shared module — no framework imports, no side effects, safe to import from anywhere in the frontend including server-side SvelteKit actions. Paraglide's `m.xxx()` calls are stateless; this is fine. - Extracting `normalizePersonType` from `+page.server.ts` to `$lib/person-validation.ts` breaks the test's dependency on a specific route file path — the tests now test the function in isolation, not indirectly through a route. - Both `new/+page.server.ts` and `edit/+page.server.ts` import from the same validation module — consistent cross-route behavior guaranteed. - The `PersonEditForm` is scoped to the edit context, `+page.svelte` handles the new-person context. The component boundary is reasonable. ### Suggestions (non-blocking) - `PersonEditForm.svelte` receives a `person` prop with a large inline type annotation (`personType`, `title`, `firstName`, `lastName`, `alias`, `birthYear`, `deathYear`, `notes`). As this form grows, an explicit named type or interface would make prop contracts visible at the call site rather than requiring the reader to open the component.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Test coverage for this PR is solid and addresses the gaps from the previous review round.

What's covered

  • person-validation.test.ts — 8 test cases covering all branches of validatePersonFields (null, empty string, PERSON/INSTITUTION/GROUP/UNKNOWN type differences)
  • page.server.test.ts — 6 test cases covering normalizePersonType including null default and SKIP→UNKNOWN normalization
  • PersonTypeSelector.svelte.spec.ts — 4 browser tests including the keyboard navigation regression test
  • PersonControllerTest.java — SKIP rejection test now asserts jsonPath("$.code").value("INVALID_PERSON_TYPE") — confirms the error envelope matches what the frontend expects

Suggestions (non-blocking)

  1. Test file name misleads: page.server.test.ts only tests normalizePersonType imported from $lib/person-validation. The actual load() and form actions in +page.server.ts are not covered. Consider renaming to normalizePersonType.test.ts to avoid implying server action coverage that isn't there.

  2. Keyboard navigation coverage gap: The ArrowRight from PERSON → INSTITUTION case is tested. An ArrowLeft (boundary check) and wrap-around test would close the remaining keyboard path coverage.

  3. Class-name assertions are fragile: Tests that check btn.className.includes('bg-primary') will silently break on a token rename. Prefer asserting on aria-checked="true" state to verify which button is selected, and let visual regression tests handle styling.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** Test coverage for this PR is solid and addresses the gaps from the previous review round. ### What's covered - `person-validation.test.ts` — 8 test cases covering all branches of `validatePersonFields` (null, empty string, PERSON/INSTITUTION/GROUP/UNKNOWN type differences) ✅ - `page.server.test.ts` — 6 test cases covering `normalizePersonType` including null default and SKIP→UNKNOWN normalization ✅ - `PersonTypeSelector.svelte.spec.ts` — 4 browser tests including the keyboard navigation regression test ✅ - `PersonControllerTest.java` — SKIP rejection test now asserts `jsonPath("$.code").value("INVALID_PERSON_TYPE")` — confirms the error envelope matches what the frontend expects ✅ ### Suggestions (non-blocking) 1. **Test file name misleads**: `page.server.test.ts` only tests `normalizePersonType` imported from `$lib/person-validation`. The actual `load()` and form `actions` in `+page.server.ts` are not covered. Consider renaming to `normalizePersonType.test.ts` to avoid implying server action coverage that isn't there. 2. **Keyboard navigation coverage gap**: The `ArrowRight` from PERSON → INSTITUTION case is tested. An `ArrowLeft` (boundary check) and wrap-around test would close the remaining keyboard path coverage. 3. **Class-name assertions are fragile**: Tests that check `btn.className.includes('bg-primary')` will silently break on a token rename. Prefer asserting on `aria-checked="true"` state to verify which button is selected, and let visual regression tests handle styling.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No new security risks introduced. Several security-relevant details are handled correctly.

What's right

  1. Enum whitelist validation: personType: "SKIP" is rejected server-side with INVALID_PERSON_TYPE. The backend does not silently accept unknown enum values — this is the correct approach.
  2. Server-side trimming: title is trimmed before persistence. Prevents whitespace-only values from bypassing @Size or display checks.
  3. maxlength="50" on title input: Consistent with any backend-side length constraint. Reduces oversized payload in the normal case.
  4. Permission gate unchanged: @RequirePermission(Permission.WRITE_ALL) still guards create/update endpoints — no permission regression.
  5. Error code in test: The PersonControllerTest now asserts the error code in the response body, confirming that the error envelope format is contract-tested. If the error structure changes, the test breaks — good defensive move.

Nothing to flag

No injection vectors, no new data exposure surface, no authentication bypass paths. Clean.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No new security risks introduced. Several security-relevant details are handled correctly. ### What's right 1. **Enum whitelist validation**: `personType: "SKIP"` is rejected server-side with `INVALID_PERSON_TYPE`. The backend does not silently accept unknown enum values — this is the correct approach. ✅ 2. **Server-side trimming**: `title` is trimmed before persistence. Prevents whitespace-only values from bypassing `@Size` or display checks. ✅ 3. **`maxlength="50"` on title input**: Consistent with any backend-side length constraint. Reduces oversized payload in the normal case. ✅ 4. **Permission gate unchanged**: `@RequirePermission(Permission.WRITE_ALL)` still guards create/update endpoints — no permission regression. ✅ 5. **Error code in test**: The `PersonControllerTest` now asserts the error code in the response body, confirming that the error envelope format is contract-tested. If the error structure changes, the test breaks — good defensive move. ✅ ### Nothing to flag No injection vectors, no new data exposure surface, no authentication bypass paths. Clean.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. Nothing to block.

What's checked

  • No Flyway migration files added or modified — no deployment ordering concern.
  • No Docker Compose changes — existing service definitions unchanged.
  • No CI/CD workflow changes.
  • No new environment variables or secrets required.

Observation

Translation keys (form_label_person_type, form_label_name, a11y_type_changed, error_invalid_person_type, validation_last_name_required, validation_first_name_required) are added to all three locale files (de, en, es) in a single commit. No missing-locale runtime error risk.

LGTM from a platform perspective.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. Nothing to block. ### What's checked - No Flyway migration files added or modified — no deployment ordering concern. ✅ - No Docker Compose changes — existing service definitions unchanged. ✅ - No CI/CD workflow changes. ✅ - No new environment variables or secrets required. ✅ ### Observation Translation keys (`form_label_person_type`, `form_label_name`, `a11y_type_changed`, `error_invalid_person_type`, `validation_last_name_required`, `validation_first_name_required`) are added to all three locale files (de, en, es) in a single commit. No missing-locale runtime error risk. ✅ LGTM from a platform perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Checking the implementation against the expected acceptance criteria for this feature.

Acceptance criteria — all fulfilled

Criterion Status
Person type selector (PERSON / INSTITUTION / GROUP / UNKNOWN) on create and edit forms
Title field shown only for PERSON type
First name field shown and required only for PERSON type
Last name label changes to "Name" for INSTITUTION/GROUP
Title displayed above display name in PersonCard (PERSON only, small-caps)
SKIP type rejected server-side with a descriptive error code
All new strings present in de/en/es locales

Legacy data handling

normalizePersonType maps SKIP → UNKNOWN when loading existing records. Legacy persons with personType = SKIP display as "Unbekannt" rather than breaking the UI or being silently dropped. This is the correct migration path — no data loss, clean user-visible representation.

Future consideration (out of scope for this PR)

The UNKNOWN type is always visible in the selector. From a user perspective, deliberately choosing "Unknown" is a valid action (e.g. "I found a letter but don't know the sender type"), but could be confusing to new users. A future UX iteration might consider hiding UNKNOWN unless the current record already has that value, and surfacing it as "can't determine type." Not a blocker — the current implementation is correct and complete for the stated requirements.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Checking the implementation against the expected acceptance criteria for this feature. ### Acceptance criteria — all fulfilled | Criterion | Status | |-----------|--------| | Person type selector (PERSON / INSTITUTION / GROUP / UNKNOWN) on create and edit forms | ✅ | | Title field shown only for PERSON type | ✅ | | First name field shown and required only for PERSON type | ✅ | | Last name label changes to "Name" for INSTITUTION/GROUP | ✅ | | Title displayed above display name in PersonCard (PERSON only, small-caps) | ✅ | | SKIP type rejected server-side with a descriptive error code | ✅ | | All new strings present in de/en/es locales | ✅ | ### Legacy data handling `normalizePersonType` maps `SKIP → UNKNOWN` when loading existing records. Legacy persons with `personType = SKIP` display as "Unbekannt" rather than breaking the UI or being silently dropped. This is the correct migration path — no data loss, clean user-visible representation. ### Future consideration (out of scope for this PR) The UNKNOWN type is always visible in the selector. From a user perspective, deliberately choosing "Unknown" is a valid action (e.g. "I found a letter but don't know the sender type"), but could be confusing to new users. A future UX iteration might consider hiding UNKNOWN unless the current record already has that value, and surfacing it as "can't determine type." Not a blocker — the current implementation is correct and complete for the stated requirements.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility

Verdict: ⚠️ Approved with concerns

The accessibility architecture of PersonTypeSelector is correct and the previous blockers are resolved. One non-blocking concern about screen reader announcement quality.

What's right

  • role="radiogroup" + role="radio" + aria-checked — correct WAI-ARIA radiogroup pattern
  • aria-live="polite" + aria-atomic="true" region for keyboard navigation announcements — correct implementation
  • Arrow key navigation via radioGroupNav now updates Svelte state via callback — the disconnect bug is fixed
  • Semantic color tokens throughout (bg-primary, text-primary-fg, bg-surface, text-ink, border-line, ring-focus-ring) — consistent with the design system
  • Title field appears before first name in the form grid — correct UX ordering for honorific/academic titles
  • maxlength="50" on the title input — prevents overlong entries without a confusing error message

Concern: Screen reader announcement uses raw type value

File: PersonTypeSelector.svelte — the select() function sets announcement using the raw type string (e.g. "INSTITUTION"), so a screen reader announces "Typ geändert zu INSTITUTION" instead of "Typ geändert zu Institution".

The localized type labels already exist as m.person_type_PERSON(), m.person_type_INSTITUTION(), etc. The fix is one line in the select() function:

// Instead of using the raw value in the announcement:
announcement = m.a11y_type_changed({ type: v });

// Use the localized label:
announcement = m.a11y_type_changed({ type: m[`person_type_${v}`]() });

This is a quality-of-life issue for screen reader users, not a functionality blocker. Suggest addressing before merge or opening a follow-up issue.

Minor

  • The for attribute on the "Typ" label (<p class={labelCls}>) is a <p> tag, not a <label>. It has no for attribute because the radiogroup doesn't have a single focusable target. This is semantically correct — the <div role="radiogroup"> should have aria-labelledby pointing to this label paragraph. Consider adding id="persontype-label" to the <p> and aria-labelledby="persontype-label" to the radiogroup <div> for a complete accessible name chain. Non-blocking.
## 🎨 Leonie Voss — UI/UX & Accessibility **Verdict: ⚠️ Approved with concerns** The accessibility architecture of `PersonTypeSelector` is correct and the previous blockers are resolved. One non-blocking concern about screen reader announcement quality. ### What's right - `role="radiogroup"` + `role="radio"` + `aria-checked` — correct WAI-ARIA radiogroup pattern ✅ - `aria-live="polite"` + `aria-atomic="true"` region for keyboard navigation announcements — correct implementation ✅ - Arrow key navigation via `radioGroupNav` now updates Svelte state via callback — the disconnect bug is fixed ✅ - Semantic color tokens throughout (`bg-primary`, `text-primary-fg`, `bg-surface`, `text-ink`, `border-line`, `ring-focus-ring`) — consistent with the design system ✅ - Title field appears before first name in the form grid — correct UX ordering for honorific/academic titles ✅ - `maxlength="50"` on the title input — prevents overlong entries without a confusing error message ✅ ### Concern: Screen reader announcement uses raw type value **File**: `PersonTypeSelector.svelte` — the `select()` function sets `announcement` using the raw type string (e.g. `"INSTITUTION"`), so a screen reader announces *"Typ geändert zu INSTITUTION"* instead of *"Typ geändert zu Institution"*. The localized type labels already exist as `m.person_type_PERSON()`, `m.person_type_INSTITUTION()`, etc. The fix is one line in the `select()` function: ```typescript // Instead of using the raw value in the announcement: announcement = m.a11y_type_changed({ type: v }); // Use the localized label: announcement = m.a11y_type_changed({ type: m[`person_type_${v}`]() }); ``` This is a quality-of-life issue for screen reader users, not a functionality blocker. Suggest addressing before merge or opening a follow-up issue. ### Minor - The `for` attribute on the "Typ" label (`<p class={labelCls}>`) is a `<p>` tag, not a `<label>`. It has no `for` attribute because the radiogroup doesn't have a single focusable target. This is semantically correct — the `<div role="radiogroup">` should have `aria-labelledby` pointing to this label paragraph. Consider adding `id="persontype-label"` to the `<p>` and `aria-labelledby="persontype-label"` to the radiogroup `<div>` for a complete accessible name chain. Non-blocking.
marcel added 4 commits 2026-04-26 12:36:52 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(persons): extract inputCls/labelCls and PersonFormData type
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m26s
CI / OCR Service Tests (push) Successful in 1m4s
CI / Backend Unit Tests (push) Failing after 3m18s
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
1f7509413d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed

All non-blocking suggestions from the third review round resolved in 4 commits.


Leonie minor — aria-label missing on radiogroup div

Commit: c154dcd4a11y(persons): add aria-label to PersonTypeSelector radiogroup

Added aria-label={m.form_label_person_type()} directly to <div role="radiogroup"> in PersonTypeSelector.svelte. Screen readers now announce the group name without requiring an aria-labelledby ID coordinate between parent and child. Test added: radiogroup has an accessible name via aria-label.

Note: Leonie's concern about the announcement using a raw type value ("INSTITUTION") was already fixed in the previous round — select() uses labels[type]() which resolves to the localized label. No change needed there.


Sara/Felix — CSS class assertions replaced with aria-checked behavior tests

Sara — ArrowLeft keyboard navigation test added

Commit: 409a8fc8test(persons): replace fragile CSS class tests with aria-checked behavior tests

Removed 3 tests asserting className.includes('bg-primary') etc. Replaced with:

  • exactly one button is aria-checked=true for the initial value
  • aria-checked=true moves to clicked button on click
  • selected button has tabindex=0, unselected buttons have tabindex=-1

Also added: hidden input value updates when user navigates with ArrowLeft (wraps around) — confirms PERSON wraps to UNKNOWN on ArrowLeft.


Sara — page.server.test.ts renamed to normalizePersonType.test.ts

Commit: da1270c4refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts


Felix — inputCls/labelCls extracted in PersonEditForm.svelte

Markus — PersonFormData named type exported from person-validation.ts

Commit: 1f750941refactor(persons): extract inputCls/labelCls and PersonFormData type

PersonFormData is now exported from $lib/person-validation.ts and used as the prop type in PersonEditForm.svelte, replacing the 8-field inline type annotation. inputCls and labelCls constants are extracted in PersonEditForm.svelte, consistent with new/+page.svelte.


Test results

  • Frontend server: 385/385
  • Frontend client (PersonTypeSelector): 6/6
## Review concerns addressed ✅ All non-blocking suggestions from the third review round resolved in 4 commits. --- ### ✅ Leonie minor — `aria-label` missing on radiogroup div **Commit:** `c154dcd4` — `a11y(persons): add aria-label to PersonTypeSelector radiogroup` Added `aria-label={m.form_label_person_type()}` directly to `<div role="radiogroup">` in `PersonTypeSelector.svelte`. Screen readers now announce the group name without requiring an `aria-labelledby` ID coordinate between parent and child. Test added: `radiogroup has an accessible name via aria-label`. Note: Leonie's concern about the announcement using a raw type value (`"INSTITUTION"`) was already fixed in the previous round — `select()` uses `labels[type]()` which resolves to the localized label. No change needed there. --- ### ✅ Sara/Felix — CSS class assertions replaced with `aria-checked` behavior tests ### ✅ Sara — ArrowLeft keyboard navigation test added **Commit:** `409a8fc8` — `test(persons): replace fragile CSS class tests with aria-checked behavior tests` Removed 3 tests asserting `className.includes('bg-primary')` etc. Replaced with: - `exactly one button is aria-checked=true for the initial value` - `aria-checked=true moves to clicked button on click` - `selected button has tabindex=0, unselected buttons have tabindex=-1` Also added: `hidden input value updates when user navigates with ArrowLeft (wraps around)` — confirms PERSON wraps to UNKNOWN on ArrowLeft. --- ### ✅ Sara — `page.server.test.ts` renamed to `normalizePersonType.test.ts` **Commit:** `da1270c4` — `refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts` --- ### ✅ Felix — `inputCls`/`labelCls` extracted in `PersonEditForm.svelte` ### ✅ Markus — `PersonFormData` named type exported from `person-validation.ts` **Commit:** `1f750941` — `refactor(persons): extract inputCls/labelCls and PersonFormData type` `PersonFormData` is now exported from `$lib/person-validation.ts` and used as the prop type in `PersonEditForm.svelte`, replacing the 8-field inline type annotation. `inputCls` and `labelCls` constants are extracted in `PersonEditForm.svelte`, consistent with `new/+page.svelte`. --- ### Test results - **Frontend server:** 385/385 ✅ - **Frontend client (PersonTypeSelector):** 6/6 ✅
marcel merged commit 5062513ae6 into main 2026-04-26 13:37:40 +02:00
marcel deleted branch feat/issue-218-person-title-type-fields 2026-04-26 13:37:41 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#333