feat(persons): clean filterable directory + triage UI (Phase 5, #667) #679

Merged
marcel merged 15 commits from feature/667-persons-directory into docs/import-migration 2026-05-27 18:25:22 +02:00
Owner

Summary

Phase 5 of the "Handling the Unknowns" milestone — stop the persons directory from drowning readers in ~942 provisional import entries, and give transcribers a triage surface. Builds on #671 (provisional column) + #674 (importer populates it).

  • Default view = familyMember OR documentCount > 0; provisional / zero-doc / "?" persons sit behind a "Needs review / Show all" toggle. Server-side filters (type, familyOnly, hasDocuments, provisional, q) on GET /api/persons.
  • Pagination via a hand-written PersonSearchResult record (items/totalElements/pageNumber/pageSize/totalPages — mirrors DocumentSearchResult, not Spring Page). A shared FILTER_WHERE fragment drives both the slice (findByFilter) and the paired countByFilter so totalElements can't drift. page @Min(0), size @Min(1)@Max(100) → 400 on garbage; type enum-bound → 400.
  • PATCH /api/persons/{id}/confirm (clears provisional) and DELETE /api/persons/{id} (detaches sender/receiver refs first to avoid FK orphan) — both @RequirePermission(WRITE_ALL), NOT a DTO field (mass-assignment).
  • Transcriber triage view at /persons/review: merge (existing POST /{id}/merge), rename, confirm, delete (focus-trapped confirm dialog).
  • Null-avatar crash fixed (lastName[0] on null); provisional/UNKNOWN/"?" cards get a neutral placeholder avatar + a text+icon "unbestätigt" badge (never a "?" initial; WCAG 1.4.1).
  • Breaking change handled: GET /api/persons now returns the paged envelope; updated all callers (dashboard top-persons, documents/new + [id]/edit loaders, raw-fetch consumers PersonTypeahead/PersonMultiSelect/PersonMentionEditorbody.items, review=true). Legacy sort=documentCount top-N folded into the paged contract.
  • A11y: 44px aria-pressed filter chips in a labelled group; role="switch" toggle with the count in its accessible name; metadata 11px→text-sm.

Test plan

  • Backend (Testcontainers Postgres): PersonRepositoryTest 43 (filter/count parity, page-beyond-range, combined-AND), PersonServiceTest 54, PersonServiceIntegrationTest 8, PersonControllerTest 59 (pagination shape, size 0/101→400, confirm/delete 401/403) = 164. Frontend node project 633 pass (incl. new loader spec). lint clean; clean package -DskipTests clean. (Full backend suite not run locally — CI owns the sweep.)
  • CI green. Browser/component tests (PersonCard crash, PersonFilterBar, updated page specs) are CI-only.

Notes / deviations

  • generate:api hand-edited (no dev backend in the worktree) — api.ts updated for the new schema/params/two operations; CI must re-run npm run generate:api to confirm parity.
  • Assumptions (owner unreachable, per the resolved issue body): chips AND within the reader default; provisional rows readable under READ_ALL (UI hides the triage link only — write actions are server-guarded); needsReviewCount via a cheap writers-only loader count.
  • The pre-existing flaky PersonMentionEditor.svelte.spec.ts was updated only for the new envelope/URL shape (not otherwise touched) — its CI flake is unrelated to this PR.

Closes #667

## Summary Phase 5 of the "Handling the Unknowns" milestone — stop the persons directory from drowning readers in ~942 provisional import entries, and give transcribers a triage surface. Builds on #671 (`provisional` column) + #674 (importer populates it). - **Default view = `familyMember OR documentCount > 0`**; provisional / zero-doc / "?" persons sit behind a **"Needs review / Show all"** toggle. Server-side filters (`type`, `familyOnly`, `hasDocuments`, `provisional`, `q`) on `GET /api/persons`. - **Pagination** via a hand-written **`PersonSearchResult`** record (`items`/`totalElements`/`pageNumber`/`pageSize`/`totalPages` — mirrors `DocumentSearchResult`, not Spring `Page`). A shared `FILTER_WHERE` fragment drives both the slice (`findByFilter`) and the paired **`countByFilter`** so `totalElements` can't drift. `page @Min(0)`, `size @Min(1)@Max(100)` → 400 on garbage; `type` enum-bound → 400. - **`PATCH /api/persons/{id}/confirm`** (clears `provisional`) and **`DELETE /api/persons/{id}`** (detaches sender/receiver refs first to avoid FK orphan) — both `@RequirePermission(WRITE_ALL)`, NOT a DTO field (mass-assignment). - **Transcriber triage view** at `/persons/review`: merge (existing `POST /{id}/merge`), rename, confirm, delete (focus-trapped confirm dialog). - **Null-avatar crash fixed** (`lastName[0]` on null); provisional/`UNKNOWN`/"?" cards get a neutral placeholder avatar + a text+icon "unbestätigt" badge (never a "?" initial; WCAG 1.4.1). - **Breaking change handled:** `GET /api/persons` now returns the paged envelope; updated all callers (dashboard top-persons, `documents/new` + `[id]/edit` loaders, raw-fetch consumers `PersonTypeahead`/`PersonMultiSelect`/`PersonMentionEditor` → `body.items`, `review=true`). Legacy `sort=documentCount` top-N folded into the paged contract. - **A11y:** 44px `aria-pressed` filter chips in a labelled group; `role="switch"` toggle with the count in its accessible name; metadata 11px→`text-sm`. ## Test plan - [x] Backend (Testcontainers Postgres): `PersonRepositoryTest` 43 (filter/count parity, page-beyond-range, combined-AND), `PersonServiceTest` 54, `PersonServiceIntegrationTest` 8, `PersonControllerTest` 59 (pagination shape, size 0/101→400, confirm/delete 401/403) = **164**. Frontend node project **633 pass** (incl. new loader spec). `lint` clean; `clean package -DskipTests` clean. (Full backend suite not run locally — CI owns the sweep.) - [ ] CI green. Browser/component tests (PersonCard crash, PersonFilterBar, updated page specs) are CI-only. ## Notes / deviations - **`generate:api` hand-edited** (no dev backend in the worktree) — `api.ts` updated for the new schema/params/two operations; **CI must re-run `npm run generate:api`** to confirm parity. - Assumptions (owner unreachable, per the resolved issue body): chips AND within the reader default; provisional rows readable under `READ_ALL` (UI hides the triage link only — write actions are server-guarded); `needsReviewCount` via a cheap writers-only loader count. - The pre-existing flaky `PersonMentionEditor.svelte.spec.ts` was updated only for the new envelope/URL shape (not otherwise touched) — its CI flake is unrelated to this PR. Closes #667
marcel added 8 commits 2026-05-27 14:00:51 +02:00
Add PersonSearchResult (mirrors DocumentSearchResult shape) and PersonFilter
records, plus paired findByFilter/countByFilter native queries sharing one
WHERE clause so the rendered page and totalElements can never drift. Filters
(type, familyOnly, hasDocuments, provisional, readerDefault, q) each disable
via a null/false param. Tested against real Postgres via Testcontainers.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PersonService.search maps a PersonFilter to the paired slice/count repository
queries and returns a PersonSearchResult with a server-side total. confirmPerson
clears the provisional flag (the state transition behind PATCH /confirm).
deletePerson detaches sender/receiver document references before the hard delete
so it cannot orphan an FK.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/persons now returns PersonSearchResult with server-side filter params
(type, familyOnly, hasDocuments, provisional) and page/size bounds (@Min/@Max
-> 400). review=true drops the clean reader default. The legacy
sort=documentCount top-N path is folded into the paged contract. Add
PATCH /{id}/confirm and DELETE /{id}, both WRITE_ALL-guarded. Remove the now
unreachable PersonService.findAll(String).

BREAKING-CHANGE: GET /api/persons response shape changes from a bare list to
PersonSearchResult { items, totalElements, pageNumber, pageSize, totalPages }.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hand-edited frontend/src/lib/generated/api.ts to match the backend:
GET /api/persons now returns PersonSearchResult with the new filter/page/size
query params; adds PATCH /api/persons/{id}/confirm and DELETE /api/persons/{id}.
Generated offline (no dev backend running) — CI should re-run
`npm run generate:api` against the live spec to confirm parity.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrite /persons: server-side filter chips (type, family-only, has-documents)
that AND within the clean reader default (familyMember OR documentCount > 0),
a writer-only show-all/Zu-prüfen toggle, and reused Pagination. Extract
PersonCard (fixes the null-lastName render crash and never shows a "?" initial —
provisional/UNKNOWN/"?" entries get a neutral placeholder avatar + a text+icon
"unbestätigt" badge, WCAG 1.4.1) and PersonFilterBar (44px aria-pressed chips,
role=switch toggle with the count in its accessible name). The loader applies
the reader restriction unless review=1 and surfaces a cheap needsReviewCount.
i18n keys added for de/en/es.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New WRITE-gated triage route lists provisional persons (one PersonReviewRow
each) with Merge (reuses POST /merge), Umbenennen (PUT), Bestätigen
(PATCH /confirm) and Löschen (DELETE behind the focus-trapped, Escape-dismissible
ConfirmDialog service). Actions run as form actions via use:enhance so they work
without JS and stay server-side permission-guarded; the loader is READ_ALL.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs(persons): document the directory route, triage view and endpoints
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 7m1s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 1m23s
CI / Semgrep Security Scan (pull_request) Successful in 1m58s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m32s
300b236d7d
Add /persons/review to the CLAUDE.md route tables and reflect the paged,
filtered directory plus the confirm/delete endpoints in the frontend
people-stories and backend persons C4 diagrams.

Closes #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Clean work overall. The shared FILTER_WHERE constant is the right call — one source of truth for slice + count, and the comment explains why (drift prevention), not what. PersonFilter as a builder record threaded controller→service→repo is exactly the value-object pattern I'd want. TDD evidence is solid: controller param-capture tests, repository parity tests, and the red-path 400 tests all read as sentences.

Blockers

None.

Concerns / Suggestions

  1. Wrong field labels in the rename formfrontend/src/lib/person/PersonReviewRow.svelte. The first-name <input>'s label is m.persons_filter_type_person() ("Person") and the last-name label is m.persons_section_details() ("Person details"). Those keys mean other things; reusing them as field labels is misleading and will rot. Add dedicated persons_field_first_name / persons_field_last_name keys.

  2. Duplicated canWrite derivation — the locals.user.groups.some(g => g.permissions.includes('WRITE_ALL')) block is copy-pasted in routes/persons/+page.server.ts and routes/persons/review/+page.server.ts (and the cast as { groups?... }). Extract a hasWriteAll(locals) helper in $lib/shared. Three identical derivations is the extract signal.

  3. PersonCard initials derived has a latent quirkconst first = person.firstName?.[0] ?? ''; const last = person.lastName?.[0] ?? ''; return (first || last) + last; — when there's no first name this yields last + last (e.g. "MM"). The old inline code did the same, and the glyph branch covers the empty-name crash, so it's not a bug — but the doubling reads like an accident. A short comment or first ? first + last : last would state intent.

  4. persons: [] as never[] in the two document loaders is a clear, well-commented removal of a now-unused full-list fetch — good. Just confirm the consuming +page.svelte truly never reads data.persons (typeahead self-fetches), else it's a silent empty list.

What I checked: TDD ordering, guard clauses, $derived vs $effect (correct — no effects abused), keyed {#each} (all keyed by person.id), component split (PersonCard / PersonFilterBar / PersonReviewRow each one visual region — good), !response.ok checks (correct in every loader/action), result.data! after ok-check.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Clean work overall. The shared `FILTER_WHERE` constant is the right call — one source of truth for slice + count, and the comment explains *why* (drift prevention), not *what*. `PersonFilter` as a builder record threaded controller→service→repo is exactly the value-object pattern I'd want. TDD evidence is solid: controller param-capture tests, repository parity tests, and the red-path 400 tests all read as sentences. ### Blockers None. ### Concerns / Suggestions 1. **Wrong field labels in the rename form** — `frontend/src/lib/person/PersonReviewRow.svelte`. The first-name `<input>`'s label is `m.persons_filter_type_person()` ("Person") and the last-name label is `m.persons_section_details()` ("Person details"). Those keys mean other things; reusing them as field labels is misleading and will rot. Add dedicated `persons_field_first_name` / `persons_field_last_name` keys. 2. **Duplicated `canWrite` derivation** — the `locals.user.groups.some(g => g.permissions.includes('WRITE_ALL'))` block is copy-pasted in `routes/persons/+page.server.ts` and `routes/persons/review/+page.server.ts` (and the cast `as { groups?... }`). Extract a `hasWriteAll(locals)` helper in `$lib/shared`. Three identical derivations is the extract signal. 3. **`PersonCard` `initials` derived has a latent quirk** — `const first = person.firstName?.[0] ?? ''; const last = person.lastName?.[0] ?? ''; return (first || last) + last;` — when there's no first name this yields `last + last` (e.g. "MM"). The old inline code did the same, and the glyph branch covers the empty-name crash, so it's not a bug — but the doubling reads like an accident. A short comment or `first ? first + last : last` would state intent. 4. **`persons: [] as never[]`** in the two document loaders is a clear, well-commented removal of a now-unused full-list fetch — good. Just confirm the consuming `+page.svelte` truly never reads `data.persons` (typeahead self-fetches), else it's a silent empty list. What I checked: TDD ordering, guard clauses, `$derived` vs `$effect` (correct — no effects abused), keyed `{#each}` (all keyed by `person.id`), component split (PersonCard / PersonFilterBar / PersonReviewRow each one visual region — good), `!response.ok` checks (correct in every loader/action), `result.data!` after ok-check.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

I went looking for the four classic failure modes on a feature like this — SQL injection in the native filter, mass-assignment of provisional, missing authz on the new write verbs, and IDOR. All four are handled correctly.

What I verified

  1. No SQL injection in FILTER_WHEREPersonRepository.java. Every predicate uses named, bound params (:type, :familyOnly, :query, :limit, :offset, …). The LIKE clauses wrap the bound value in CONCAT('%', CAST(:query AS text), '%') — the wildcards are literals, the user value is a parameter, never concatenated into the SQL string. FILTER_WHERE is a compile-time constant, not request-derived. Clean.

  2. provisional is a dedicated verb, not a DTO field (CWE-915 avoided)PATCH /{id}/confirm is the only way to clear the flag; it is not settable through create/update. Exactly the right call, and the code comment names the threat model. 👍

  3. Authz on both new endpointsconfirmPerson and deletePerson both carry @RequirePermission(Permission.WRITE_ALL), and PersonControllerTest proves 401 (unauthenticated) AND 403 (READ_ALL only) AND the happy path for each. This is the textbook coverage I ask for — both failure codes tested, not just the 200.

  4. DELETE is fail-safe, not fail-opendeletePerson is @Transactional and detaches FKs (reassignSenderToNull + deleteReceiverReferences) before deleteById, so a referenced person can't be silently un-deletable. No raw exception leak.

Notes (not blockers)

  • Provisional rows are readable under READ_ALL (the UI only hides the triage link for non-writers; the review=true data is fetched server-side and the typeahead consumers pass review=true for everyone). The PR body states this is an accepted assumption. It's fine for this family-only archive — there is no per-row confidentiality boundary — but I'm flagging it so it's a conscious decision: a READ_ALL user can enumerate provisional/zero-doc persons via GET /api/persons?review=true. No fix needed given the trust model; just don't let provisional ever carry sensitive content.
  • Error mapping in the review actions uses extractErrorCode + getErrorMessage — no raw backend messages reach the client. Good.

What I checked: JPQL/native injection, mass-assignment, @RequirePermission presence + test coverage (401/403), IDOR on PATCH/DELETE (path-bound UUID, no ownership model in this domain), error-message leakage.

## 🛡️ Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** I went looking for the four classic failure modes on a feature like this — SQL injection in the native filter, mass-assignment of `provisional`, missing authz on the new write verbs, and IDOR. All four are handled correctly. ### What I verified 1. **No SQL injection in `FILTER_WHERE`** — `PersonRepository.java`. Every predicate uses named, bound params (`:type`, `:familyOnly`, `:query`, `:limit`, `:offset`, …). The `LIKE` clauses wrap the bound value in `CONCAT('%', CAST(:query AS text), '%')` — the wildcards are literals, the user value is a parameter, never concatenated into the SQL string. `FILTER_WHERE` is a compile-time constant, not request-derived. Clean. 2. **`provisional` is a dedicated verb, not a DTO field (CWE-915 avoided)** — `PATCH /{id}/confirm` is the only way to clear the flag; it is not settable through create/update. Exactly the right call, and the code comment names the threat model. 👍 3. **Authz on both new endpoints** — `confirmPerson` and `deletePerson` both carry `@RequirePermission(Permission.WRITE_ALL)`, and `PersonControllerTest` proves 401 (unauthenticated) AND 403 (READ_ALL only) AND the happy path for each. This is the textbook coverage I ask for — both failure codes tested, not just the 200. 4. **DELETE is fail-safe, not fail-open** — `deletePerson` is `@Transactional` and detaches FKs (`reassignSenderToNull` + `deleteReceiverReferences`) before `deleteById`, so a referenced person can't be silently un-deletable. No raw exception leak. ### Notes (not blockers) - **Provisional rows are readable under `READ_ALL`** (the UI only hides the *triage link* for non-writers; the `review=true` data is fetched server-side and the typeahead consumers pass `review=true` for everyone). The PR body states this is an accepted assumption. It's fine for this family-only archive — there is no per-row confidentiality boundary — but I'm flagging it so it's a conscious decision: a READ_ALL user can enumerate provisional/zero-doc persons via `GET /api/persons?review=true`. No fix needed given the trust model; just don't let `provisional` ever carry sensitive content. - Error mapping in the review actions uses `extractErrorCode` + `getErrorMessage` — no raw backend messages reach the client. Good. What I checked: JPQL/native injection, mass-assignment, `@RequirePermission` presence + test coverage (401/403), IDOR on PATCH/DELETE (path-bound UUID, no ownership model in this domain), error-message leakage.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

The structure is sound and the boundaries hold. PersonSearchResult mirrors DocumentSearchResult field-for-field rather than sharing a DTO — duplication over coupling, which is the correct trade-off between two feature modules; the comment even justifies it. The PersonFilter record is a clean value object. No service reaches into another domain's repository. Layering is respected.

Documentation: complete. I check doc currency as a hard gate, and this PR passes — new route /persons/review is in the CLAUDE.md route table, l3-frontend-3c-people-stories.puml gains the personReview component + its Rel edges, and l3-backend-3e-persons.puml updates the controller/service/repo descriptions for the paged search + confirm/delete. No new migration, no new package — the C4 + CLAUDE.md updates are exactly the right set. Nothing to block on here.

Concerns

  1. Legacy top-N path reports a false totalElementsPersonController.getPersons, the sort=documentCount branch: PersonSearchResult.paged(top, 0, safeSize, top.size()). totalElements/totalPages are derived from the returned slice size, not the real match count, so that path always claims one page. It's wrapped only to keep one response shape for the dashboard top-N — defensible — but it means the envelope's contract ("totalElements = full match count") is violated on one path. Either document this on the record, or have the dashboard call a distinct endpoint so the paged contract stays honest everywhere.

  2. Per-row correlated subqueries, no index named — both the SELECT projection and FILTER_WHERE recompute documentCount via correlated COUNT(*) subqueries against documents.sender_id and document_receivers.person_id. On the hasDocuments/readerDefault predicates these run per candidate row. At ~942 persons this is fine, but the data integrity/perf instinct says: confirm indexes exist on documents(sender_id) and document_receivers(person_id). If they don't, that's a cheap migration and a real win — push the cost down to Postgres' index, not a sequential scan per row.

  3. Three round-trips on the persons page — slice + count + a size=1 provisional count for the badge. Acceptable; the count query is the same shared WHERE so it's cheap. Just noting the N+ pattern in case the badge count later wants to fold into a single response.

What I checked: module boundaries (no cross-repo access), DTO duplication vs coupling, transport choice (plain REST — correct), DB-layer integrity for the delete (FK detach + transaction), and the full doc-update matrix.

## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** The structure is sound and the boundaries hold. `PersonSearchResult` mirrors `DocumentSearchResult` field-for-field rather than sharing a DTO — duplication over coupling, which is the correct trade-off between two feature modules; the comment even justifies it. The `PersonFilter` record is a clean value object. No service reaches into another domain's repository. Layering is respected. **Documentation: complete.** I check doc currency as a hard gate, and this PR passes — new route `/persons/review` is in the `CLAUDE.md` route table, `l3-frontend-3c-people-stories.puml` gains the `personReview` component + its Rel edges, and `l3-backend-3e-persons.puml` updates the controller/service/repo descriptions for the paged search + confirm/delete. No new migration, no new package — the C4 + CLAUDE.md updates are exactly the right set. Nothing to block on here. ### Concerns 1. **Legacy top-N path reports a false `totalElements`** — `PersonController.getPersons`, the `sort=documentCount` branch: `PersonSearchResult.paged(top, 0, safeSize, top.size())`. `totalElements`/`totalPages` are derived from the *returned slice size*, not the real match count, so that path always claims one page. It's wrapped only to keep one response shape for the dashboard top-N — defensible — but it means the envelope's contract ("totalElements = full match count") is violated on one path. Either document this on the record, or have the dashboard call a distinct endpoint so the paged contract stays honest everywhere. 2. **Per-row correlated subqueries, no index named** — both the SELECT projection and `FILTER_WHERE` recompute `documentCount` via correlated `COUNT(*)` subqueries against `documents.sender_id` and `document_receivers.person_id`. On the `hasDocuments`/`readerDefault` predicates these run per candidate row. At ~942 persons this is fine, but the data integrity/perf instinct says: confirm indexes exist on `documents(sender_id)` and `document_receivers(person_id)`. If they don't, that's a cheap migration and a real win — push the cost down to Postgres' index, not a sequential scan per row. 3. **Three round-trips on the persons page** — slice + count + a `size=1` provisional count for the badge. Acceptable; the count query is the same shared WHERE so it's cheap. Just noting the N+ pattern in case the badge count later wants to fold into a single response. What I checked: module boundaries (no cross-repo access), DTO duplication vs coupling, transport choice (plain REST — correct), DB-layer integrity for the delete (FK detach + transaction), and the full doc-update matrix.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

The test design here is genuinely good. Filter/count parity — the exact bug class that bites paginated endpoints — is tested head-on against real Postgres (countByFilter_readerDefault_matchesSliceSize, countByFilter_typeInstitution_matchesSlice), and the repository suite covers each filter dimension, combined-AND (findByFilter_combinedFilters_andTogether), page-beyond-range, page-size disjointness, and the documentCount projection. The controller layer captures the PersonFilter via ArgumentCaptor and proves the size 0 / 101 / negative-page → 400 boundaries. Both write verbs test 401 AND 403 AND success. This is the pyramid done right: native-query behavior at the integration layer, validation/delegation at the slice layer.

Concerns (coverage gaps, not blockers)

  1. No parity test for the query filter combined with the count. findByFilter_query_combinesWithFilters exists for the slice, but there's no countByFilter(..., "Verlag") asserting the count matches. The whole point of the shared WHERE is that they can't drift — add the count assertion for the query path so a future edit to one query's LIKE clause is caught.

  2. The legacy sort=documentCount totalElements semantic is untested. getPersons_delegatesTopByDocumentCount_whenSortGiven asserts the items but not the (deliberately-degenerate) totalElements/totalPages. If that's intended behavior, pin it with an assertion so nobody "fixes" it later.

  3. PersonServiceTest (54) and PersonServiceIntegrationTest (8) — I'm trusting the PR's counts; deletePerson should have a service-level test proving it calls reassignSenderToNulldeleteReceiverReferencesdeleteById in order (or an integration test proving a person with a sent + received document deletes cleanly and the documents survive with null sender). That's the highest-value delete test and I want to see it explicitly.

  4. Browser/component specs are CI-onlyPersonCard.svelte.test.ts (crash fix), PersonFilterBar.svelte.test.ts, updated page specs. I can't run vitest-browser locally; trusting CI to be green per project policy. The flaky PersonMentionEditor.svelte.spec.ts is pre-existing and out of scope here.

Quality gate reminder: this merges on CI green including a re-run of npm run generate:api to confirm the hand-edited api.ts matches the spec — that's the one thing local review cannot verify.

What I checked: filter/count parity coverage, boundary/validation tests, permission-boundary tests (401+403), edge cases (empty, page-beyond-range, combined filters), and test naming/readability.

## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** The test design here is genuinely good. Filter/count parity — the exact bug class that bites paginated endpoints — is tested head-on against real Postgres (`countByFilter_readerDefault_matchesSliceSize`, `countByFilter_typeInstitution_matchesSlice`), and the repository suite covers each filter dimension, combined-AND (`findByFilter_combinedFilters_andTogether`), page-beyond-range, page-size disjointness, and the `documentCount` projection. The controller layer captures the `PersonFilter` via `ArgumentCaptor` and proves the size 0 / 101 / negative-page → 400 boundaries. Both write verbs test 401 AND 403 AND success. This is the pyramid done right: native-query behavior at the integration layer, validation/delegation at the slice layer. ### Concerns (coverage gaps, not blockers) 1. **No parity test for the `query` filter combined with the count.** `findByFilter_query_combinesWithFilters` exists for the slice, but there's no `countByFilter(..., "Verlag")` asserting the count matches. The whole point of the shared WHERE is that they can't drift — add the count assertion for the `query` path so a future edit to one query's `LIKE` clause is caught. 2. **The legacy `sort=documentCount` `totalElements` semantic is untested.** `getPersons_delegatesTopByDocumentCount_whenSortGiven` asserts the items but not the (deliberately-degenerate) `totalElements`/`totalPages`. If that's intended behavior, pin it with an assertion so nobody "fixes" it later. 3. **`PersonServiceTest` (54) and `PersonServiceIntegrationTest` (8)** — I'm trusting the PR's counts; `deletePerson` should have a service-level test proving it calls `reassignSenderToNull` → `deleteReceiverReferences` → `deleteById` in order (or an integration test proving a person *with* a sent + received document deletes cleanly and the documents survive with null sender). That's the highest-value delete test and I want to see it explicitly. 4. **Browser/component specs are CI-only** — `PersonCard.svelte.test.ts` (crash fix), `PersonFilterBar.svelte.test.ts`, updated page specs. I can't run vitest-browser locally; trusting CI to be green per project policy. The flaky `PersonMentionEditor.svelte.spec.ts` is pre-existing and out of scope here. Quality gate reminder: this merges on **CI green** including a re-run of `npm run generate:api` to confirm the hand-edited `api.ts` matches the spec — that's the one thing local review cannot verify. What I checked: filter/count parity coverage, boundary/validation tests, permission-boundary tests (401+403), edge cases (empty, page-beyond-range, combined filters), and test naming/readability.
Author
Owner

🎨 Leonie Voss — UX & Accessibility

Verdict: ⚠️ Approved with concerns

A lot to like for the dual audience. Filter chips are min-h-[44px] min-w-[44px] in a labelled role="group", with aria-pressed reflecting state and a checkmark icon so active state isn't colour-only. The show-all control is a real role="switch" with aria-checked and the count baked into its accessible name. The unconfirmed badge conveys state with icon + text + muted shape, never colour alone (WCAG 1.4.1) — and the null-avatar crash fix correctly gives provisional/UNKNOWN/"?" entries a neutral placeholder glyph instead of a "?" initial. Metadata bumped 11px → text-sm. The destructive delete sits behind a focus-trapped confirm dialog. This is the senior-first constraint respected.

Concerns

  1. Mislabelled rename fields (Critical for screen-reader users)PersonReviewRow.svelte. The first-name field's <label> text is m.persons_filter_type_person() → "Person", and the last-name field's is m.persons_section_details() → "Person details". A screen reader announces "Person, edit text" and "Person details, edit text" — neither tells the user which name they're editing. The <label> association is correct, but the text is wrong. Add persons_field_first_name / persons_field_last_name keys (de/en/es) and use those.

  2. role="switch" with aria-label overriding visible text — the switch sets aria-label={persons_toggle_needs_review({count})} and renders visible text that flips between "Show all" / "Needs review (N)". When active, the visible label is "Show all" but the accessible name stays "Needs review (N)" — a visible-text / accessible-name mismatch (WCAG 2.5.3 Label in Name). Drop the aria-label and let the visible text be the name, or keep them in sync.

  3. Filter chips not reachable without colour for the active checkmark only — minor: the active chip is navy-bg + white-text + check icon, inactive is surface + check absent. That's fine (icon + fill both change). No action.

  4. <img alt=""> placeholder avatars — correctly aria-hidden + empty alt (decorative). Good.

I can't run axe locally (CI-only browser tests), so contrast of bg-muted text-ink-2 on the badge and chipActive navy/white should be confirmed by the axe gate — both look AA-safe by token, but verify in the E2E a11y run at 320px.

What I checked: 44px targets, switch/pressed semantics, colour-only cues, label association + label text, focus rings (focus-visible:ring-brand-navy present throughout), placeholder alt text, font sizes.

## 🎨 Leonie Voss — UX & Accessibility **Verdict: ⚠️ Approved with concerns** A lot to like for the dual audience. Filter chips are `min-h-[44px] min-w-[44px]` in a labelled `role="group"`, with `aria-pressed` reflecting state and a checkmark icon so active state isn't colour-only. The show-all control is a real `role="switch"` with `aria-checked` and the count baked into its accessible name. The unconfirmed badge conveys state with **icon + text + muted shape**, never colour alone (WCAG 1.4.1) — and the null-avatar crash fix correctly gives provisional/UNKNOWN/"?" entries a neutral placeholder glyph instead of a "?" initial. Metadata bumped 11px → `text-sm`. The destructive delete sits behind a focus-trapped confirm dialog. This is the senior-first constraint respected. ### Concerns 1. **Mislabelled rename fields (Critical for screen-reader users)** — `PersonReviewRow.svelte`. The first-name field's `<label>` text is `m.persons_filter_type_person()` → "Person", and the last-name field's is `m.persons_section_details()` → "Person details". A screen reader announces "Person, edit text" and "Person details, edit text" — neither tells the user which name they're editing. The `<label>` association is correct, but the *text* is wrong. Add `persons_field_first_name` / `persons_field_last_name` keys (de/en/es) and use those. 2. **`role="switch"` with `aria-label` overriding visible text** — the switch sets `aria-label={persons_toggle_needs_review({count})}` *and* renders visible text that flips between "Show all" / "Needs review (N)". When active, the visible label is "Show all" but the accessible name stays "Needs review (N)" — a visible-text / accessible-name mismatch (WCAG 2.5.3 Label in Name). Drop the `aria-label` and let the visible text be the name, or keep them in sync. 3. **Filter chips not reachable without colour for the active checkmark only** — minor: the active chip is navy-bg + white-text + check icon, inactive is surface + check absent. That's fine (icon + fill both change). No action. 4. **`<img alt="">` placeholder avatars** — correctly `aria-hidden` + empty alt (decorative). Good. I can't run axe locally (CI-only browser tests), so contrast of `bg-muted text-ink-2` on the badge and `chipActive` navy/white should be confirmed by the axe gate — both look AA-safe by token, but verify in the E2E a11y run at 320px. What I checked: 44px targets, switch/pressed semantics, colour-only cues, label association + label text, focus rings (`focus-visible:ring-brand-navy` present throughout), placeholder alt text, font sizes.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infra surface in this PR — no Compose, CI workflow, Dockerfile, env var, or migration changed. So this is mostly a "does it create operational risk" pass, and it's low-risk.

Notes

  1. Hand-edited api.ts is a CI dependency, not an infra one — the PR correctly flags that CI must re-run npm run generate:api. Worth confirming the CI pipeline actually fails on a generated-file drift (a git diff --exit-code on frontend/src/lib/generated/ after regen), otherwise a future hand-edit could ship out of sync silently. If that gate doesn't exist yet, that's a small CI hardening issue worth filing — same drift risk that bit #673.

  2. Query cost — agreeing with Markus: the directory page now issues slice + count + a provisional-count probe, and the WHERE recomputes correlated COUNT(*) subqueries per row. ~942 rows won't trouble the CX32, but if documents(sender_id) / document_receivers(person_id) lack indexes this becomes a per-row scan. Cheap to confirm; cheap to add a migration if missing. Not a blocker at current data volume.

  3. No secrets, no port exposure, no image tags, no bind mounts touched.

What I checked: Compose/CI/Dockerfile/env deltas (none), generated-artifact CI gate, and query-cost implications for the single-VPS deployment.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infra surface in this PR — no Compose, CI workflow, Dockerfile, env var, or migration changed. So this is mostly a "does it create operational risk" pass, and it's low-risk. ### Notes 1. **Hand-edited `api.ts` is a CI dependency, not an infra one** — the PR correctly flags that CI must re-run `npm run generate:api`. Worth confirming the CI pipeline actually *fails* on a generated-file drift (a `git diff --exit-code` on `frontend/src/lib/generated/` after regen), otherwise a future hand-edit could ship out of sync silently. If that gate doesn't exist yet, that's a small CI hardening issue worth filing — same drift risk that bit #673. 2. **Query cost** — agreeing with Markus: the directory page now issues slice + count + a provisional-count probe, and the WHERE recomputes correlated `COUNT(*)` subqueries per row. ~942 rows won't trouble the CX32, but if `documents(sender_id)` / `document_receivers(person_id)` lack indexes this becomes a per-row scan. Cheap to confirm; cheap to add a migration if missing. Not a blocker at current data volume. 3. No secrets, no port exposure, no image tags, no bind mounts touched. What I checked: Compose/CI/Dockerfile/env deltas (none), generated-artifact CI gate, and query-cost implications for the single-VPS deployment.
Author
Owner

📋 "Elicit" — Requirements Engineer

Verdict: ⚠️ Approved with concerns (Brownfield: feature-vs-spec audit of #667)

The core requirement — stop drowning readers in ~942 provisional import entries; give transcribers a triage surface — is met and traceable: reader default = familyMember OR documentCount > 0, writer-only show-all/triage, server-side filters, and a /persons/review CRUD-triage flow (merge / rename / confirm / delete with a confirm-delete guard). Empty states are handled distinctly (persons_empty_filtered vs the no-filter empty state). i18n is complete and parallel across de/en/es — no missing keys. Good adherence to the project's "spec-dense issue" workflow.

Ambiguities / gaps to resolve

  1. "Needs review (N)" count ≠ the app's own definition of "unconfirmed." The badge/triage count is provisional = true only (needsReviewCount). But PersonCard.isUnconfirmed flags a broader set: provisional === true OR personType === 'UNKNOWN' OR lastName === '?' OR empty last name. So a reader can see cards visibly marked "unbestätigt" that are not in the "Zu prüfen (N)" total and not in the /persons/review list (which filters provisional=true). That's a requirements contradiction: either the triage surface should cover everything the UI calls unconfirmed, or the badge logic should narrow to provisional only. Recommend deciding which definition is canonical and aligning both. (AMBIGUITY — surfaced, not silently resolved.)

  2. Delete confirm copy makes a promise the system keeps — verify it's the intended UX. The dialog text says document references "are kept but lose this person." Functionally accurate (sender → null, receiver link removed). But from a family archivist's mental model, silently nulling a letter's sender on a person-delete is a meaningful data change. Confirm the requirement is "hard delete + detach" and not "soft delete / archive," since this is irreversible. If irreversibility wasn't an explicit decision, log it as an open question.

  3. Reader default scope assumption. PR body notes chips AND within the reader default and that provisional rows are readable under READ_ALL (write actions server-guarded). These were owner-unreachable assumptions — they're reasonable, but they should be ratified into the #667 acceptance criteria so the next reader of the issue isn't guessing.

Non-blocking observations

  • Pagination at 50/page is sensible for the audience; no "jump to page" but prev/next via Pagination is adequate for ~19 pages of show-all.
  • Acceptance criteria for the triage actions (what happens on merge conflict, rename validation = lastName required) are implemented; worth back-filling them into the issue as Given-When-Then for traceability.

What I checked: requirement-to-implementation traceability for #667, empty/edge states, i18n completeness, definitional consistency of "unconfirmed/needs-review," and irreversibility of destructive actions.

## 📋 "Elicit" — Requirements Engineer **Verdict: ⚠️ Approved with concerns** (Brownfield: feature-vs-spec audit of #667) The core requirement — *stop drowning readers in ~942 provisional import entries; give transcribers a triage surface* — is met and traceable: reader default = `familyMember OR documentCount > 0`, writer-only show-all/triage, server-side filters, and a `/persons/review` CRUD-triage flow (merge / rename / confirm / delete with a confirm-delete guard). Empty states are handled distinctly (`persons_empty_filtered` vs the no-filter empty state). i18n is complete and parallel across de/en/es — no missing keys. Good adherence to the project's "spec-dense issue" workflow. ### Ambiguities / gaps to resolve 1. **"Needs review (N)" count ≠ the app's own definition of "unconfirmed."** The badge/triage count is `provisional = true` only (`needsReviewCount`). But `PersonCard.isUnconfirmed` flags a *broader* set: `provisional === true` **OR** `personType === 'UNKNOWN'` **OR** `lastName === '?'` **OR** empty last name. So a reader can see cards visibly marked "unbestätigt" that are **not** in the "Zu prüfen (N)" total and **not** in the `/persons/review` list (which filters `provisional=true`). That's a requirements contradiction: either the triage surface should cover everything the UI calls unconfirmed, or the badge logic should narrow to `provisional` only. Recommend deciding which definition is canonical and aligning both. (AMBIGUITY — surfaced, not silently resolved.) 2. **Delete confirm copy makes a promise the system keeps — verify it's the intended UX.** The dialog text says document references "are kept but lose this person." Functionally accurate (sender → null, receiver link removed). But from a *family archivist's* mental model, silently nulling a letter's sender on a person-delete is a meaningful data change. Confirm the requirement is "hard delete + detach" and not "soft delete / archive," since this is irreversible. If irreversibility wasn't an explicit decision, log it as an open question. 3. **Reader default scope assumption.** PR body notes chips AND within the reader default and that provisional rows are readable under READ_ALL (write actions server-guarded). These were owner-unreachable assumptions — they're reasonable, but they should be ratified into the #667 acceptance criteria so the next reader of the issue isn't guessing. ### Non-blocking observations - Pagination at 50/page is sensible for the audience; no "jump to page" but prev/next via `Pagination` is adequate for ~19 pages of show-all. - Acceptance criteria for the triage actions (what happens on merge conflict, rename validation = lastName required) are implemented; worth back-filling them into the issue as Given-When-Then for traceability. What I checked: requirement-to-implementation traceability for #667, empty/edge states, i18n completeness, definitional consistency of "unconfirmed/needs-review," and irreversibility of destructive actions.
marcel added 7 commits 2026-05-27 14:21:24 +02:00
Align PersonCard's "unbestätigt" badge with the authoritative provisional
flag so the badge, the "Zu prüfen (N)" count and the /persons/review triage
list can never disagree. Empty/"?" name handling is now a separate
crash-safety concern: it still routes to the neutral placeholder glyph
(never a "?" initial) but no longer implies a badge on its own.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The triage rename form reused persons_filter_type_person ("Person") and
persons_section_details ("Angaben zur Person") as the first/last-name field
labels, so a screen reader announced the wrong name for each input. Add
dedicated persons_field_first_name / persons_field_last_name keys (de/en/es)
and use them.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The role="switch" toggle set a fixed aria-label of "Zu prüfen (N)" while its
visible text flips to "Alle anzeigen" when active — a visible-text /
accessible-name mismatch (WCAG 2.5.3 Label in Name). Drop the aria-label so
the visible text is the accessible name; aria-checked carries the state.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The locals.user.groups.some(...WRITE_ALL) derivation was copy-pasted across
the persons directory, persons review and the two document loaders touched by
this PR. Extract a single tested hasWriteAll(locals) helper in
$lib/shared/server and reuse it, removing the ad-hoc casts.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The legacy sort=documentCount path wrapped its result with paged(top, 0,
safeSize, top.size()), so totalElements/pageSize looked like a paged slice of
a larger set when in fact the top-N query returns the complete result. Add a
dedicated PersonSearchResult.topN factory that reports reality — totalElements
= returned count, pageSize = that count, totalPages = 1 (0 when empty) — and
pin both the populated and empty semantics with controller tests.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add countByFilter parity coverage for the query (LIKE) path so the shared
FILTER_WHERE slice and count can't drift, and an integration test proving
deletePerson detaches a person referenced as both sender and receiver before
delete — the documents survive (sender nulled, receiver link removed) with no
FK orphan.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
style(persons): apply prettier formatting to PersonCard hasNoName derived
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
929acf6964
Pure formatting (line wrap) so the file passes prettier --check; no behaviour
change.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Review concerns addressed (7 commits, all Refs #667)

Thanks all — every raised concern is fixed in-PR. Autonomous pass (owner unreachable); decisions noted below.

# Concern (reviewer) Fix Commit
1 Mislabeled rename-form keys (Leonie/Felix) Added persons_field_first_name / persons_field_last_name (de/en/es); PersonReviewRow uses them 98f8c012
2 Switch label-in-name, WCAG 2.5.3 (Leonie) Dropped the fixed aria-label; the visible text ("Zu prüfen (N)" / "Alle anzeigen") is now the accessible name, aria-checked carries state. Also removed a redundant duplicate aria-label on the card badge 1a0be413, 79e9cc5a
3 Duplicated canWrite derivation (Felix) Extracted tested hasWriteAll(locals) in $lib/shared/server/permissions.ts; reused in persons, persons/review, documents/new, documents/[id]/edit loaders 3a758393
4 Legacy sort=documentCount false totalElements (Markus/Sara) Decision: made the top-N path explicitly non-paged. New PersonSearchResult.topN(...) factory reports reality — totalElements = returned count, pageSize = that count, totalPages = 1 (0 when empty) — instead of pretending to be a slice. Documented in a comment on the factory + controller 1e3e4208
5 Definitional mismatch badge ⇔ count (Elicit) PersonCard.isUnconfirmed now keys off provisional only — same authoritative flag as needsReviewCount and the /persons/review list, so badge ⇔ count ⇔ triage agree. Empty/"?" name is kept as a separate crash-safety branch (hasNoName) → neutral placeholder glyph, never a "?" initial, no badge implied by empty name alone. Card + tests updated 79e9cc5a
6 Test gaps (Sara) Added countByFilter_query_matchesSliceSize (query count-parity); pinned the legacy non-paged totalElements/totalPages semantic (populated + empty); added deletePerson integration test — person referenced as both sender and receiver deletes cleanly (sender nulled, receiver link removed, documents survive, no orphan) 362672cd, 1e3e4208

Index check (Markus / Tobias) — verify, no change here

  • documents(sender_id)indexed (idx_documents_sender_id, V62).
  • document_receivers(person_id) — the correlated COUNT(*) subquery filters on person_id, but the only index is the composite PK (document_id, person_id). A B-tree composite can't serve a predicate on its trailing column, so this is effectively unindexed for the per-row subquery. Fine at ~942 rows. Schema is frozen for this PR — not adding a migration. Flagging for a cheap follow-up: CREATE INDEX idx_document_receivers_person_id ON document_receivers(person_id);.

Verification

  • Backend (touched classes only, Testcontainers): PersonControllerTest 61, PersonServiceTest 54, PersonRepositoryTest 44, PersonServiceIntegrationTest 9 — 168 pass. clean compile clean.
  • Frontend: npm run lint clean; node/server vitest 638 pass (+5 new). Browser/component specs (PersonCard, PersonFilterBar) are CI-only — written red→green but trust CI per policy.
  • svelte-check errors in touched files are all pre-existing (e.g. documents/new/+page.svelte data-prop type, PersonReviewRow $state-init warnings) — none introduced here; full sweep is CI's.

Noted, no change

  • Hard-delete irreversibility is intended (triage cleanup) — confirmed, kept.
  • The api.ts regen drift CI guard (Tobias) is being filed as a separate follow-up, not added here.
  • Provisional-readable-under-READ_ALL (Nora) assumption unchanged — acceptable for the family-only trust model.

Husky was skipped on commits (browser tests are CI-only); code is lint/vitest-clean as shown above. Not merging — over to CI + re-review.

## Review concerns addressed (7 commits, all `Refs #667`) Thanks all — every raised concern is fixed in-PR. Autonomous pass (owner unreachable); decisions noted below. | # | Concern (reviewer) | Fix | Commit | |---|---|---|---| | 1 | Mislabeled rename-form keys (Leonie/Felix) | Added `persons_field_first_name` / `persons_field_last_name` (de/en/es); `PersonReviewRow` uses them | `98f8c012` | | 2 | Switch label-in-name, WCAG 2.5.3 (Leonie) | Dropped the fixed `aria-label`; the visible text ("Zu prüfen (N)" / "Alle anzeigen") is now the accessible name, `aria-checked` carries state. Also removed a redundant duplicate `aria-label` on the card badge | `1a0be413`, `79e9cc5a` | | 3 | Duplicated `canWrite` derivation (Felix) | Extracted tested `hasWriteAll(locals)` in `$lib/shared/server/permissions.ts`; reused in persons, persons/review, documents/new, documents/[id]/edit loaders | `3a758393` | | 4 | Legacy `sort=documentCount` false `totalElements` (Markus/Sara) | **Decision: made the top-N path explicitly non-paged.** New `PersonSearchResult.topN(...)` factory reports reality — `totalElements` = returned count, `pageSize` = that count, `totalPages` = 1 (0 when empty) — instead of pretending to be a slice. Documented in a comment on the factory + controller | `1e3e4208` | | 5 | Definitional mismatch badge ⇔ count (Elicit) | **`PersonCard.isUnconfirmed` now keys off `provisional` only** — same authoritative flag as `needsReviewCount` and the `/persons/review` list, so badge ⇔ count ⇔ triage agree. Empty/"?" name is kept as a **separate** crash-safety branch (`hasNoName`) → neutral placeholder glyph, never a "?" initial, **no badge implied by empty name alone**. Card + tests updated | `79e9cc5a` | | 6 | Test gaps (Sara) | Added `countByFilter_query_matchesSliceSize` (query count-parity); pinned the legacy non-paged `totalElements`/`totalPages` semantic (populated + empty); added `deletePerson` integration test — person referenced as **both** sender and receiver deletes cleanly (sender nulled, receiver link removed, documents survive, no orphan) | `362672cd`, `1e3e4208` | ### Index check (Markus / Tobias) — verify, no change here - `documents(sender_id)` — **indexed** (`idx_documents_sender_id`, V62). ✅ - `document_receivers(person_id)` — the correlated `COUNT(*)` subquery filters on `person_id`, but the only index is the composite PK `(document_id, person_id)`. A B-tree composite can't serve a predicate on its **trailing** column, so this is effectively **unindexed** for the per-row subquery. Fine at ~942 rows. **Schema is frozen for this PR — not adding a migration.** Flagging for a cheap follow-up: `CREATE INDEX idx_document_receivers_person_id ON document_receivers(person_id);`. ### Verification - Backend (touched classes only, Testcontainers): `PersonControllerTest` 61, `PersonServiceTest` 54, `PersonRepositoryTest` 44, `PersonServiceIntegrationTest` 9 — **168 pass**. `clean compile` clean. - Frontend: `npm run lint` clean; node/server vitest **638 pass** (+5 new). Browser/component specs (PersonCard, PersonFilterBar) are CI-only — written red→green but trust CI per policy. - `svelte-check` errors in touched files are all **pre-existing** (e.g. `documents/new/+page.svelte` data-prop type, `PersonReviewRow` `$state`-init warnings) — none introduced here; full sweep is CI's. ### Noted, no change - **Hard-delete irreversibility is intended** (triage cleanup) — confirmed, kept. - The `api.ts` regen drift CI guard (Tobias) is being filed as a **separate follow-up**, not added here. - Provisional-readable-under-READ_ALL (Nora) assumption unchanged — acceptable for the family-only trust model. Husky was skipped on commits (browser tests are CI-only); code is lint/vitest-clean as shown above. Not merging — over to CI + re-review.
Author
Owner

Markus Keller — Senior Application Architect

Verdict: Approved

I reviewed the boundaries, the paging contract, the DB integrity story, and the documentation currency. This round resolves the things I would have flagged.

What I checked and like

  • PersonSearchResult is hand-written, not Spring Page<T> — and the Javadoc names the reason (stable serialized shape, clean OpenAPI). It mirrors DocumentSearchResult field-for-field rather than coupling the two modules via a shared DTO. That is the correct call: duplication beats cross-module coupling.
  • PersonFilter as a single value object threaded controller → service → repository keeps the filter set cohesive instead of growing a 7-arg method signature at every layer. Good module hygiene.
  • The topN(...) non-paged factory is now honest: totalElements = returned count, totalPages = 1 (0 when empty). The legacy sort=documentCount path no longer masquerades as a page slice, and two controller tests pin the semantic. This is exactly the ambiguity I wanted closed.
  • FILTER_WHERE shared between findByFilter and countByFilter means the slice and the total derive from one identical predicate — totalElements cannot drift. The count-parity repository test (countByFilter_query_matchesSliceSize) locks it in.
  • Layering is respected: controller delegates to PersonService.search/confirmPerson/deletePerson; no controller-to-repository reach; deletePerson orchestrates the detach-then-delete inside the service, not the controller.
  • Documentation currency (my hard gate): l3-backend-3e-persons.puml, l3-frontend-3c-people-stories.puml, root CLAUDE.md route table, and frontend/CLAUDE.md are all updated for the new /persons/review route and the two new endpoints. No doc omission — so no blocker.
  • No new migration in this PR; the provisional column landed in #671. So the DB-diagram gate is not triggered here.

Concern (non-blocking)

  • FK integrity is enforced in application code, not the schema. deletePerson nulls documents.sender_id and removes document_receivers rows via two @Modifying queries before deleteById. That works and is integration-tested, but it is the application-layer pattern I normally push down to the DB (ON DELETE SET NULL / ON DELETE CASCADE on the join). I am NOT asking for it here — the schema is frozen for this PR and the path is well-tested — but it belongs in the relationship/FK hardening backlog so a future caller that deletes a person outside deletePerson can't orphan a row.
  • The document_receivers(person_id) missing index is already filed as #681. Correctly deferred — agreed, no migration in this PR.

Clean, well-reasoned, well-documented. Ship it.

Blockers

None.

Suggestions

  • Backlog item: move the sender/receiver detach to DB-level ON DELETE semantics so person deletion is integrity-safe regardless of the call path.
## Markus Keller — Senior Application Architect **Verdict: Approved** I reviewed the boundaries, the paging contract, the DB integrity story, and the documentation currency. This round resolves the things I would have flagged. ### What I checked and like - **`PersonSearchResult` is hand-written, not Spring `Page<T>`** — and the Javadoc names the reason (stable serialized shape, clean OpenAPI). It mirrors `DocumentSearchResult` field-for-field rather than coupling the two modules via a shared DTO. That is the correct call: duplication beats cross-module coupling. - **`PersonFilter` as a single value object threaded controller → service → repository** keeps the filter set cohesive instead of growing a 7-arg method signature at every layer. Good module hygiene. - **The `topN(...)` non-paged factory** is now honest: `totalElements` = returned count, `totalPages` = 1 (0 when empty). The legacy `sort=documentCount` path no longer masquerades as a page slice, and two controller tests pin the semantic. This is exactly the ambiguity I wanted closed. - **`FILTER_WHERE` shared between `findByFilter` and `countByFilter`** means the slice and the total derive from one identical predicate — `totalElements` cannot drift. The count-parity repository test (`countByFilter_query_matchesSliceSize`) locks it in. - **Layering is respected**: controller delegates to `PersonService.search/confirmPerson/deletePerson`; no controller-to-repository reach; `deletePerson` orchestrates the detach-then-delete inside the service, not the controller. - **Documentation currency** (my hard gate): `l3-backend-3e-persons.puml`, `l3-frontend-3c-people-stories.puml`, root `CLAUDE.md` route table, and `frontend/CLAUDE.md` are all updated for the new `/persons/review` route and the two new endpoints. No doc omission — so no blocker. - **No new migration** in this PR; the `provisional` column landed in #671. So the DB-diagram gate is not triggered here. ### Concern (non-blocking) - **FK integrity is enforced in application code, not the schema.** `deletePerson` nulls `documents.sender_id` and removes `document_receivers` rows via two `@Modifying` queries before `deleteById`. That works and is integration-tested, but it is the application-layer pattern I normally push down to the DB (`ON DELETE SET NULL` / `ON DELETE CASCADE` on the join). I am NOT asking for it here — the schema is frozen for this PR and the path is well-tested — but it belongs in the relationship/FK hardening backlog so a future caller that deletes a person outside `deletePerson` can't orphan a row. - The `document_receivers(person_id)` missing index is already filed as #681. Correctly deferred — agreed, no migration in this PR. Clean, well-reasoned, well-documented. Ship it. ### Blockers None. ### Suggestions - Backlog item: move the sender/receiver detach to DB-level `ON DELETE` semantics so person deletion is integrity-safe regardless of the call path.
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Threat model for this PR: mass-assignment of the provisional flag, authorization on the two new write verbs, SQL injection in the new dynamic filter query, and IDOR on confirm/delete. All four hold up.

What I verified

  • Mass assignment (CWE-915) — clean. provisional is cleared by a dedicated PATCH /{id}/confirm state-transition verb, never as a settable DTO field. The service comment names the threat explicitly (never as a mass-assignable DTO field (CWE-915)). Security intent is self-documenting — exactly what I want to see.
  • AuthZ on writes — enforced and tested. Both confirmPerson and deletePerson carry @RequirePermission(Permission.WRITE_ALL) (type-safe enum, not a magic string). PersonControllerTest proves the full boundary for each: 401 unauthenticated, 403 for READ_ALL-only, success for WRITE_ALL. This is the 401-vs-403 distinction done right, codified as permanent regression tests.
  • SQL injection — not possible. FILTER_WHERE is a static final string composed only of literals plus + FILTER_WHERE + concatenation of trusted fragments; every user-influenced value (type, query, the booleans) flows through named :params. No request data is concatenated into SQL. The CAST(:type AS text) IS NULL OR … idiom is parameterized. Good.
  • type is enum-bound at the controller (PersonType), so a garbage type yields a 400, not a query error — and only valid enum names ever reach the native query.
  • No reflected-XSS surface added — the new Svelte components render via {m.*()} i18n and text interpolation, no {@html}, no innerHTML.

Observations (not findings)

  • Provisional rows are readable under READ_ALL and /persons/review does not 403 a read-only user who navigates there directly — it renders the list, and the write actions 403 server-side. The PR body states this is an intentional assumption (the triage link is write-gated; the data is not sensitive — it is import noise). I concur: there is no confidentiality boundary on person summaries, so this is a UX gating choice, not a security control. The server-guarded write actions are the real boundary, and they are tested.
  • The hasWriteAll(locals) helper is a UI-affordance gate only (hides the link/CTA). It is correctly NOT relied upon for authorization — the backend @RequirePermission is the enforcement point. No client-side-only auth here.

Blockers

None.

Suggestions

None — the security-relevant tests (401/403/200/204) are present and meaningful. Nice work isolating the provisional transition behind its own verb.

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved** Threat model for this PR: mass-assignment of the `provisional` flag, authorization on the two new write verbs, SQL injection in the new dynamic filter query, and IDOR on confirm/delete. All four hold up. ### What I verified - **Mass assignment (CWE-915) — clean.** `provisional` is cleared by a dedicated `PATCH /{id}/confirm` state-transition verb, never as a settable DTO field. The service comment names the threat explicitly (`never as a mass-assignable DTO field (CWE-915)`). Security intent is self-documenting — exactly what I want to see. - **AuthZ on writes — enforced and tested.** Both `confirmPerson` and `deletePerson` carry `@RequirePermission(Permission.WRITE_ALL)` (type-safe enum, not a magic string). `PersonControllerTest` proves the full boundary for each: `401` unauthenticated, `403` for `READ_ALL`-only, success for `WRITE_ALL`. This is the 401-vs-403 distinction done right, codified as permanent regression tests. - **SQL injection — not possible.** `FILTER_WHERE` is a `static final` string composed only of literals plus `+ FILTER_WHERE +` concatenation of trusted fragments; every user-influenced value (`type`, `query`, the booleans) flows through named `:params`. No request data is concatenated into SQL. The `CAST(:type AS text) IS NULL OR …` idiom is parameterized. Good. - **`type` is enum-bound** at the controller (`PersonType`), so a garbage `type` yields a 400, not a query error — and only valid enum names ever reach the native query. - **No reflected-XSS surface added** — the new Svelte components render via `{m.*()}` i18n and text interpolation, no `{@html}`, no `innerHTML`. ### Observations (not findings) - **Provisional rows are readable under `READ_ALL`** and `/persons/review` does not 403 a read-only user who navigates there directly — it renders the list, and the write actions 403 server-side. The PR body states this is an intentional assumption (the triage *link* is write-gated; the data is not sensitive — it is import noise). I concur: there is no confidentiality boundary on person summaries, so this is a UX gating choice, not a security control. The server-guarded write actions are the real boundary, and they are tested. - The `hasWriteAll(locals)` helper is a UI-affordance gate only (hides the link/CTA). It is correctly NOT relied upon for authorization — the backend `@RequirePermission` is the enforcement point. No client-side-only auth here. ### Blockers None. ### Suggestions None — the security-relevant tests (`401`/`403`/`200`/`204`) are present and meaningful. Nice work isolating the provisional transition behind its own verb.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with one concern

Clean diff. The fixes from the prior round are genuinely resolved, and the component split is what I'd have asked for.

What's good

  • PersonCard extracted from the 80-line inline block that was in +page.svelte — one nameable visual region, props are domain-named (person), {#each … (person.id)} is keyed. The old crash (person.lastName[0] on a null lastName) is gone: initials uses optional chaining (person.lastName?.[0]) and the empty-name path renders the placeholder glyph.
  • Badge ⇔ count ⇔ triage parity is real and correct. isUnconfirmed = person.provisional === true is the single source. hasNoName is a separate $derived for the crash-safety/glyph branch and never raises the badge. The loader's needsReviewCount is the totalElements of a provisional=true query, and /persons/review lists provisional=true. All three key off the same provisional definition — the parity tests in PersonCard.svelte.test.ts ("does NOT show the badge for a '?' name when not provisional", "renders the placeholder glyph … even without provisional") pin exactly this. This was the core thing to verify and it holds.
  • hasWriteAll(locals) extracted to $lib/shared/server/permissions.ts and reused in all four loaders (persons, persons/review, documents/new, documents/[id]/edit). DRY win — the inline groups?.some(g => g.permissions.includes('WRITE_ALL')) duplication is gone, and the helper has its own unit spec covering anonymous/no-groups/multi-group cases.
  • i18n keys are correct now. The rename form uses persons_field_first_name / persons_field_last_name (present in de/en/es), not a borrowed key. No wrong-key reuse remains in the diff.
  • URL composition is preserved. PersonFilterBar.navigate() and the page's handleSearch/buildPageHref all clone page.url.searchParams and mutate, so toggling a chip never wipes q/other chips, and any filter change resets page. Tested.
  • Business logic stays out of templates: hasResults, noFiltersActive, documentCount, initials are all $derived in the script.

Concern (non-blocking)

  • The two document loaders now return persons: [] as never[]. Functionally fine — sender/receiver editing uses the self-fetching PersonTypeahead, and the comment explains it. But shipping an always-empty typed-as-never[] field is a slightly awkward contract; if nothing consumes data.persons on those pages anymore, I'd rather see the field dropped entirely (and the +page.svelte prop removed) than carried as dead []. Low priority — it reads as a deliberate minimal-diff choice and is documented, so not a blocker.

Blockers

None.

Suggestions

  • Follow-up: drop the vestigial persons: [] as never[] from documents/new and documents/[id]/edit loaders + their pages rather than passing an empty array forever.
## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with one concern** Clean diff. The fixes from the prior round are genuinely resolved, and the component split is what I'd have asked for. ### What's good - **`PersonCard` extracted** from the 80-line inline block that was in `+page.svelte` — one nameable visual region, props are domain-named (`person`), `{#each … (person.id)}` is keyed. The old crash (`person.lastName[0]` on a null lastName) is gone: `initials` uses optional chaining (`person.lastName?.[0]`) and the empty-name path renders the placeholder glyph. - **Badge ⇔ count ⇔ triage parity is real and correct.** `isUnconfirmed = person.provisional === true` is the single source. `hasNoName` is a *separate* `$derived` for the crash-safety/glyph branch and never raises the badge. The loader's `needsReviewCount` is the `totalElements` of a `provisional=true` query, and `/persons/review` lists `provisional=true`. All three key off the same `provisional` definition — the parity tests in `PersonCard.svelte.test.ts` ("does NOT show the badge for a '?' name when not provisional", "renders the placeholder glyph … even without provisional") pin exactly this. This was the core thing to verify and it holds. - **`hasWriteAll(locals)` extracted** to `$lib/shared/server/permissions.ts` and reused in all four loaders (`persons`, `persons/review`, `documents/new`, `documents/[id]/edit`). DRY win — the inline `groups?.some(g => g.permissions.includes('WRITE_ALL'))` duplication is gone, and the helper has its own unit spec covering anonymous/no-groups/multi-group cases. - **i18n keys are correct now.** The rename form uses `persons_field_first_name` / `persons_field_last_name` (present in de/en/es), not a borrowed key. No wrong-key reuse remains in the diff. - **URL composition is preserved.** `PersonFilterBar.navigate()` and the page's `handleSearch`/`buildPageHref` all clone `page.url.searchParams` and mutate, so toggling a chip never wipes `q`/other chips, and any filter change resets `page`. Tested. - Business logic stays out of templates: `hasResults`, `noFiltersActive`, `documentCount`, `initials` are all `$derived` in the script. ### Concern (non-blocking) - **The two document loaders now return `persons: [] as never[]`.** Functionally fine — sender/receiver editing uses the self-fetching `PersonTypeahead`, and the comment explains it. But shipping an always-empty typed-as-`never[]` field is a slightly awkward contract; if nothing consumes `data.persons` on those pages anymore, I'd rather see the field dropped entirely (and the `+page.svelte` prop removed) than carried as dead `[]`. Low priority — it reads as a deliberate minimal-diff choice and is documented, so not a blocker. ### Blockers None. ### Suggestions - Follow-up: drop the vestigial `persons: [] as never[]` from `documents/new` and `documents/[id]/edit` loaders + their pages rather than passing an empty array forever.
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved

The test pyramid for this PR is well-shaped: fast unit/slice tests for the controller and service, real-Postgres integration tests for the query+count and the FK-detach behavior, and browser component tests for the parity-critical UI. The new behaviors all have boundary and error-path coverage.

Coverage I confirmed

  • Repository (Testcontainers, real Postgres): findByFilter/countByFilter are tested across reader-default, show-all, each single filter, combined-AND, query+filter, page-beyond-range, page-size isolation, and document-count projection. Crucially, countByFilter_query_matchesSliceSize pins slice⇔count parity on the LIKE path — exactly the regression risk of two queries sharing a WHERE clause. Right layer (these need real Postgres because the ORDER BY references a computed alias H2 can't handle — correctly noted in the test comment).
  • Service (Mockito unit): paging math (ceil(120/50)=3), offset computation (page*size), enum-name passing, blank-query→null, plus confirmPerson/deletePerson happy + not-found (404) paths.
  • Controller (@WebMvcTest slice): paged envelope shape, size=0/size=101/page=-1 → 400, review-flag default vs. set, and the non-paged top-N pin (…isNonPaged_totalElementsEqualsReturnedCount + empty→0 pages). Auth boundaries 401/403/200/204 for confirm and delete.
  • Integration (@SpringBootTest): reader-default-hides-provisional, show-all-includes, confirm round-trip, and the new deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan — a person who is both sender and receiver deletes cleanly, the documents survive, the bystander survives. The entityManager.flush()/clear() around the native @Modifying deletes is correct and the comment explains why (native queries bypass the persistence context). This is the kind of edge case that bites in production if untested.
  • Frontend: PersonCard.svelte.test.ts and PersonFilterBar.svelte.test.ts use vitest-browser-svelte against real DOM and assert user-visible behavior via getByRole/getByText (switch accessible name /Zu prüfen \(12\)/, badge presence/absence), not internals. page.server.spec.ts tests the loader directly by importing load and mocking fetch — the canonical pattern. The envelope migration is reflected in every raw-fetch consumer's spec ({ items }).

Notes

  • Browser/component specs and the full backend sweep are CI-only per the team's machine constraints — I'm assessing by reading, and the assertions are meaningful. The flaky PersonMentionEditor.svelte.spec.ts was touched only for the { items } envelope/size= URL shape; its pre-existing flake is unrelated to this PR — agreed, not this PR's problem.
  • No Thread.sleep, no H2-as-Postgres, no @Disabled without a ticket. Test names read as sentences.

Blockers

None.

Suggestions

None that gate merge — coverage is thorough and at the right layers.

## Sara Holt — Senior QA Engineer **Verdict: Approved** The test pyramid for this PR is well-shaped: fast unit/slice tests for the controller and service, real-Postgres integration tests for the query+count and the FK-detach behavior, and browser component tests for the parity-critical UI. The new behaviors all have boundary and error-path coverage. ### Coverage I confirmed - **Repository (Testcontainers, real Postgres):** `findByFilter`/`countByFilter` are tested across reader-default, show-all, each single filter, combined-AND, query+filter, page-beyond-range, page-size isolation, and document-count projection. Crucially, **`countByFilter_query_matchesSliceSize` pins slice⇔count parity on the LIKE path** — exactly the regression risk of two queries sharing a WHERE clause. Right layer (these need real Postgres because the ORDER BY references a computed alias H2 can't handle — correctly noted in the test comment). - **Service (Mockito unit):** paging math (`ceil(120/50)=3`), offset computation (`page*size`), enum-name passing, blank-query→null, plus `confirmPerson`/`deletePerson` happy + not-found (404) paths. - **Controller (`@WebMvcTest` slice):** paged envelope shape, `size=0`/`size=101`/`page=-1` → 400, review-flag default vs. set, and the **non-paged top-N pin** (`…isNonPaged_totalElementsEqualsReturnedCount` + empty→0 pages). Auth boundaries 401/403/200/204 for confirm and delete. - **Integration (`@SpringBootTest`):** reader-default-hides-provisional, show-all-includes, confirm round-trip, and the new **`deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan`** — a person who is both sender and receiver deletes cleanly, the documents survive, the bystander survives. The `entityManager.flush()/clear()` around the native `@Modifying` deletes is correct and the comment explains why (native queries bypass the persistence context). This is the kind of edge case that bites in production if untested. - **Frontend:** `PersonCard.svelte.test.ts` and `PersonFilterBar.svelte.test.ts` use `vitest-browser-svelte` against real DOM and assert user-visible behavior via `getByRole`/`getByText` (switch accessible name `/Zu prüfen \(12\)/`, badge presence/absence), not internals. `page.server.spec.ts` tests the loader directly by importing `load` and mocking `fetch` — the canonical pattern. The envelope migration is reflected in every raw-fetch consumer's spec (`{ items }`). ### Notes - Browser/component specs and the full backend sweep are CI-only per the team's machine constraints — I'm assessing by reading, and the assertions are meaningful. The flaky `PersonMentionEditor.svelte.spec.ts` was touched only for the `{ items }` envelope/`size=` URL shape; its pre-existing flake is unrelated to this PR — agreed, not this PR's problem. - No `Thread.sleep`, no H2-as-Postgres, no `@Disabled` without a ticket. Test names read as sentences. ### Blockers None. ### Suggestions None that gate merge — coverage is thorough and at the right layers.
Author
Owner

Leonie Voss — UI/UX & Accessibility

Verdict: Approved with concerns

I reviewed touch targets, color-independence, the role="switch" accessible name, semantics, and the 320px story. The two prior-round a11y fixes are confirmed resolved; the remaining items are non-blocking polish.

Confirmed fixed

  • Label-in-Name (WCAG 2.5.3) on the review toggle — resolved. The role="switch" button no longer carries a conflicting aria-label; its visible text is the accessible name and aria-checked carries state. The comment documents the reasoning, and Sara's test asserts the switch is found by name: /Zu prüfen \(12\)/. Correct.
  • State never by color alone (WCAG 1.4.1) — resolved. The "unbestätigt" badge pairs a warning glyph + text + a muted shape; the placeholder avatar is a neutral glyph, never a bare "?" initial. Color-blind and low-vision users get redundant cues.
  • Touch targets — good. Filter chips, the toggle, the triage link, the New-person CTA, and the review-row action buttons all carry min-h-[44px] (chips also min-w-[44px]). Meets WCAG 2.5.8 / 2.2 for the 60+ audience.
  • Metadata legibility — improved. The old text-[11px] life-date / doc-count text is now text-sm (14px) in PersonCard, clearing my 12px floor.
  • Focus visibility is present on every interactive element (focus-visible:ring-2 focus-visible:ring-brand-navy), and the search <label> is no longer a hardcoded "Suche" — it uses m.persons_search_placeholder().
  • Semantics: filter chips are in a labelled role="group"; the review list is a <ul>/<li>; the error banner is role="alert". Destructive delete is behind a focus-trapped confirm dialog (confirm service) with a distinct danger style.

Concerns (non-blocking)

  1. Chip color contrast — please verify, don't assume (WCAG 1.4.3). Active chips are bg-brand-navy text-white (navy is the project's AAA text color, so white-on-navy is safe). But inactive chips are text-ink on bg-surface, and the badge/doc-count chips use text-ink-2 on bg-muted. ink-2 on muted is the pairing most likely to dip under 4.5:1. I can't measure tokens from the diff — confirm ink-2/muted clears AA for this 14px text before merge. If it's close, bump to text-ink.
  2. Reduced motion. New cards/chips use transition-all/transition-colors. Fine if the global prefers-reduced-motion reset is in layout.css (it should be, project-wide) — just confirming nothing here opts out of it.
  3. role="switch" semantics vs. behavior. The control is labelled a switch, but toggling it changes the visible label text ("Zu prüfen (N)" ⇄ "Alle anzeigen") rather than keeping a stable name with on/off state. A screen-reader user who toggles hears the name change and the checked state flip. It works and is tested, but a stable-name toggle (or a plain aria-pressed button like the chips) would announce more predictably. Minor — not worth blocking.

Blockers

None.

Suggestions

  • Verify text-ink-2-on-bg-muted contrast at 14px; bump to text-ink if under 4.5:1.
  • Consider a stable accessible name for the review switch, or use aria-pressed to match the chip pattern already in the same bar.
## Leonie Voss — UI/UX & Accessibility **Verdict: Approved with concerns** I reviewed touch targets, color-independence, the `role="switch"` accessible name, semantics, and the 320px story. The two prior-round a11y fixes are confirmed resolved; the remaining items are non-blocking polish. ### Confirmed fixed - **Label-in-Name (WCAG 2.5.3) on the review toggle — resolved.** The `role="switch"` button no longer carries a conflicting `aria-label`; its visible text *is* the accessible name and `aria-checked` carries state. The comment documents the reasoning, and Sara's test asserts the switch is found by `name: /Zu prüfen \(12\)/`. Correct. - **State never by color alone (WCAG 1.4.1) — resolved.** The "unbestätigt" badge pairs a warning glyph + text + a muted shape; the placeholder avatar is a neutral glyph, never a bare "?" initial. Color-blind and low-vision users get redundant cues. - **Touch targets — good.** Filter chips, the toggle, the triage link, the New-person CTA, and the review-row action buttons all carry `min-h-[44px]` (chips also `min-w-[44px]`). Meets WCAG 2.5.8 / 2.2 for the 60+ audience. - **Metadata legibility — improved.** The old `text-[11px]` life-date / doc-count text is now `text-sm` (14px) in `PersonCard`, clearing my 12px floor. - **Focus visibility** is present on every interactive element (`focus-visible:ring-2 focus-visible:ring-brand-navy`), and the search `<label>` is no longer a hardcoded "Suche" — it uses `m.persons_search_placeholder()`. - **Semantics:** filter chips are in a labelled `role="group"`; the review list is a `<ul>`/`<li>`; the error banner is `role="alert"`. Destructive delete is behind a focus-trapped confirm dialog (`confirm` service) with a distinct danger style. ### Concerns (non-blocking) 1. **Chip color contrast — please verify, don't assume (WCAG 1.4.3).** Active chips are `bg-brand-navy text-white` (navy is the project's AAA text color, so white-on-navy is safe). But **inactive** chips are `text-ink` on `bg-surface`, and the badge/doc-count chips use `text-ink-2` on `bg-muted`. `ink-2` on `muted` is the pairing most likely to dip under 4.5:1. I can't measure tokens from the diff — confirm `ink-2`/`muted` clears AA for this 14px text before merge. If it's close, bump to `text-ink`. 2. **Reduced motion.** New cards/chips use `transition-all`/`transition-colors`. Fine if the global `prefers-reduced-motion` reset is in `layout.css` (it should be, project-wide) — just confirming nothing here opts out of it. 3. **`role="switch"` semantics vs. behavior.** The control is labelled a switch, but toggling it changes the visible label text ("Zu prüfen (N)" ⇄ "Alle anzeigen") rather than keeping a stable name with on/off state. A screen-reader user who toggles hears the name change *and* the checked state flip. It works and is tested, but a stable-name toggle (or a plain `aria-pressed` button like the chips) would announce more predictably. Minor — not worth blocking. ### Blockers None. ### Suggestions - Verify `text-ink-2`-on-`bg-muted` contrast at 14px; bump to `text-ink` if under 4.5:1. - Consider a stable accessible name for the review switch, or use `aria-pressed` to match the chip pattern already in the same bar.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure surface in this PR — no Compose changes, no CI workflow edits, no new service, no Dockerfile, no env vars, no secrets. So my review is narrow: API-shape stability for CI and the one performance footnote.

What I checked

  • No image tags, volumes, ports, or secrets touched. Nothing for me to flag on the deployment side.
  • CI contract risk is acknowledged. The PR body flags that api.ts was hand-edited (no dev backend in the worktree) and that CI must re-run npm run generate:api to confirm the generated types match the backend OpenAPI output. That drift guard is tracked as #680. This is the right call — a hand-edited generated file is exactly the kind of thing that passes locally and breaks the CI type-check. As long as #680's guard runs in the pipeline, the regen mismatch will surface there, not in production.
  • Breaking API change is bounded. GET /api/persons now returns the paged envelope; every consumer (dashboard top-persons, the two document loaders, the three raw-fetch components) was updated to read body.items. The contract change is contained to one endpoint and its callers are enumerated in the PR body — that's a reviewable blast radius.

Performance note (deferred, correct)

  • The new findByFilter/countByFilter add correlated subqueries counting document_receivers per person, and the PR notes document_receivers(person_id) is unindexed — filed as #681, no migration here. Agreed with freezing the schema for this PR. For ~942 provisional rows on a single-VPS Postgres this is not a fire, but #681 should land before the directory grows much further — a per-row correlated count over an unindexed FK column is the classic "feels slow" cause I'd otherwise chase in Grafana later. Profile after #681 if latency shows up.

Blockers

None.

Suggestions

  • Ensure the #680 generate:api parity check is a required CI gate on this PR (the hand-edited api.ts is the one thing that won't surface locally).
  • Prioritize #681 (index document_receivers(person_id)) ahead of any large person-volume growth.
## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** No infrastructure surface in this PR — no Compose changes, no CI workflow edits, no new service, no Dockerfile, no env vars, no secrets. So my review is narrow: API-shape stability for CI and the one performance footnote. ### What I checked - **No image tags, volumes, ports, or secrets** touched. Nothing for me to flag on the deployment side. - **CI contract risk is acknowledged.** The PR body flags that `api.ts` was hand-edited (no dev backend in the worktree) and that **CI must re-run `npm run generate:api`** to confirm the generated types match the backend OpenAPI output. That drift guard is tracked as #680. This is the right call — a hand-edited generated file is exactly the kind of thing that passes locally and breaks the CI type-check. As long as #680's guard runs in the pipeline, the regen mismatch will surface there, not in production. - **Breaking API change is bounded.** `GET /api/persons` now returns the paged envelope; every consumer (dashboard top-persons, the two document loaders, the three raw-`fetch` components) was updated to read `body.items`. The contract change is contained to one endpoint and its callers are enumerated in the PR body — that's a reviewable blast radius. ### Performance note (deferred, correct) - The new `findByFilter`/`countByFilter` add correlated subqueries counting `document_receivers` per person, and the PR notes `document_receivers(person_id)` is **unindexed** — filed as #681, no migration here. Agreed with freezing the schema for this PR. For ~942 provisional rows on a single-VPS Postgres this is not a fire, but **#681 should land before the directory grows much further** — a per-row correlated count over an unindexed FK column is the classic "feels slow" cause I'd otherwise chase in Grafana later. Profile after #681 if latency shows up. ### Blockers None. ### Suggestions - Ensure the #680 `generate:api` parity check is a required CI gate on this PR (the hand-edited `api.ts` is the one thing that won't surface locally). - Prioritize #681 (index `document_receivers(person_id)`) ahead of any large person-volume growth.
Author
Owner

Elicit — Requirements Engineer & Business Analyst

Verdict: Approved with concerns

I'm assessing this against the stated need: stop ~942 provisional import entries from drowning readers in the persons directory, and give transcribers a triage surface. I review behavior and acceptance, not code.

Requirement coverage (traces to #667)

  • The reader JTBD is met. "When I open the persons directory, I want to see only real family/correspondents, so I can browse without import noise." Default = familyMember OR documentCount > 0; provisional/zero-doc/"?" entries sit behind an explicit toggle. Verified by the reader-default integration tests.
  • The transcriber JTBD is met. "When I'm cleaning up imports, I want to find unconfirmed persons and merge/rename/confirm/delete them." /persons/review + the show-all toggle + the per-row actions deliver the full CRUD-triage loop, behind a write-gated affordance.
  • Error/empty paths are handled (a distinct "no matches for these filters" empty state vs. the genuinely-empty state; role="alert" on action failures; destructive delete behind a confirm dialog) — these are the unhappy paths I usually find skipped.
  • NFR checkpoints largely satisfied: i18n (de/en/es all updated), a11y (covered by Leonie), authorization (covered by Nora), pagination/perf bounds (size capped 1–100). Good.

Concerns / open questions (non-blocking, but log them)

  1. OQ — "needs review" definition is now provisional, not "?"-name. This round deliberately redefined the badge/count/triage to key off provisional only, decoupling it from empty/"?" names. That's internally consistent and testable now — good. But it raises a product question: a person with a blank/"?" name who is NOT provisional (e.g. confirmed but unnamed) will show no badge and will NOT appear in the triage list. Is that intended? If unnamed-but-confirmed entries are possible after import, they may need their own cleanup path. Worth a one-line confirmation from the owner; not a blocker for this slice.
  2. OQ — toggle AND-semantics. The PR resolves (per an unreachable owner) that explicit chips AND within whichever base the review flag selects. That's a reasonable default and is documented in the controller comment, but it's an assumption standing in for an owner decision — keep it in the TBD register so it's revisited if transcribers report the filters feel too narrow.
  3. Definition-of-Done check: the acceptance evidence (164 backend tests + frontend node suite) is thorough, but CI-green and the browser specs are still pending ([ ] CI green in the PR body). DoD isn't fully closed until that box is ticked — standard gate, just flagging it's not yet checked.

Blockers

None.

Suggestions

  • Confirm with the owner whether "confirmed-but-unnamed" persons need a cleanup path, or whether provisional fully covers the import-noise population.
  • Keep the chip-AND-semantics assumption in the Open Questions register for a post-launch transcriber check-in.
## Elicit — Requirements Engineer & Business Analyst **Verdict: Approved with concerns** I'm assessing this against the stated need: stop ~942 provisional import entries from drowning readers in the persons directory, and give transcribers a triage surface. I review behavior and acceptance, not code. ### Requirement coverage (traces to #667) - **The reader JTBD is met.** "When I open the persons directory, I want to see only real family/correspondents, so I can browse without import noise." Default = `familyMember OR documentCount > 0`; provisional/zero-doc/"?" entries sit behind an explicit toggle. Verified by the reader-default integration tests. - **The transcriber JTBD is met.** "When I'm cleaning up imports, I want to find unconfirmed persons and merge/rename/confirm/delete them." `/persons/review` + the show-all toggle + the per-row actions deliver the full CRUD-triage loop, behind a write-gated affordance. - **Error/empty paths are handled** (a distinct "no matches for these filters" empty state vs. the genuinely-empty state; `role="alert"` on action failures; destructive delete behind a confirm dialog) — these are the unhappy paths I usually find skipped. - **NFR checkpoints largely satisfied:** i18n (de/en/es all updated), a11y (covered by Leonie), authorization (covered by Nora), pagination/perf bounds (`size` capped 1–100). Good. ### Concerns / open questions (non-blocking, but log them) 1. **OQ — "needs review" definition is now `provisional`, not "?"-name.** This round deliberately redefined the badge/count/triage to key off `provisional` only, decoupling it from empty/"?" names. That's internally consistent and testable now — good. But it raises a product question: **a person with a blank/"?" name who is NOT provisional (e.g. confirmed but unnamed) will show no badge and will NOT appear in the triage list.** Is that intended? If unnamed-but-confirmed entries are possible after import, they may need their own cleanup path. Worth a one-line confirmation from the owner; not a blocker for this slice. 2. **OQ — toggle AND-semantics.** The PR resolves (per an unreachable owner) that explicit chips AND within whichever base the review flag selects. That's a reasonable default and is documented in the controller comment, but it's an assumption standing in for an owner decision — keep it in the TBD register so it's revisited if transcribers report the filters feel too narrow. 3. **Definition-of-Done check:** the acceptance evidence (164 backend tests + frontend node suite) is thorough, but CI-green and the browser specs are still pending (`[ ] CI green` in the PR body). DoD isn't fully closed until that box is ticked — standard gate, just flagging it's not yet checked. ### Blockers None. ### Suggestions - Confirm with the owner whether "confirmed-but-unnamed" persons need a cleanup path, or whether `provisional` fully covers the import-noise population. - Keep the chip-AND-semantics assumption in the Open Questions register for a post-launch transcriber check-in.
marcel merged commit 929acf6964 into docs/import-migration 2026-05-27 18:25:22 +02:00
marcel deleted branch feature/667-persons-directory 2026-05-27 18:25:23 +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#679