As a reader I want a clean persons directory so I'm not overwhelmed by unconfirmed import entries #667

Closed
opened 2026-05-26 20:33:37 +02:00 by marcel · 8 comments
Owner

⚠️ Phase 5 of the persons-import pipeline. This issue adds NO migration. The Person.provisional and source_ref columns are created by Phase 2 — #671 and populated by Phase 3 — #669. This issue only consumes the provisional flag for filtering and triage. Dependency chain: #671#669 → this issue. Do not add a Flyway migration here; do not assume the column exists at build time (a heuristic fallback is specified below for the interim).

This body was rewritten to fold in the seven-persona review (Markus, Felix, Nora, Elicit, Sara, Leonie, Tobias) and the Decision Queue. Material corrections vs. the prior draft are called out inline.


Context

After the spreadsheet import, /persons is a flat grid of ~1100 cards: ~163 curated register persons plus ~942 auto-generated provisional persons (single first-names, group labels, literal ? placeholders, UNKNOWN-type rows, zero-document entries). Everything renders in one undifferentiated grid with no type filter, no pagination, and no separation between "real" register entries and import noise. Readers — who just want to browse — are buried under question-mark and zero-document rows.

Two audiences (see device-split context):

  • Transcribers (older, 60+, on laptop/tablet) do the triage work: confirming, renaming, merging, and deleting provisional entries.
  • Readers (younger, on phones) just browse the people who actually matter.

The fix: make the default directory view clean for readers (family members and people with documents), push the messy triage behind an explicit toggle, paginate the grid server-side, and give transcribers a dedicated triage surface to clean up the import noise.

The reader-facing filter data is already on the wirePersonSummaryDTO exposes getPersonType(), isFamilyMember(), getDocumentCount(). No new summary fields are required for the reader filters; only provisional is added by the dependency chain.

Current behaviour (verified against code)

  • frontend/src/routes/persons/+page.server.ts:14-15 — loads GET /api/persons passing only q; line 30 returns the full persons array to the page. No type filter, no pagination, no slicing.
  • frontend/src/routes/persons/+page.svelte:93 — flat grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 iterating every person ({#each data.persons …}, keyed on person.id — correct, keep). Cards show avatar, PersonTypeBadge, alias, life dates, doc-count chip (only when documentCount > 0, line 158).
  • frontend/src/routes/persons/+page.svelte:132 — latent null-avatar crash (Felix). The fallback is {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}. If lastName is null/empty (a malformed provisional row), person.lastName[0] throws at render. The placeholder-avatar fix below also removes this crash — it is a bug, not polish.
  • frontend/src/routes/persons/+page.svelte:21-26 — the debounced search goto(\/persons?q=${q}`)**drops every other param** (Felix). Reusing it naively for chips would wipepage/type/reviewon each keystroke. All navigation must compose params viaURLSearchParams/page.url`, never string concatenation.
  • backend/.../person/PersonController.java:36-47getPersons(q, size, sort). Has a top-N documentCount path (sort=documentCount, size>0, q==nullfindTopByDocumentCount); otherwise personService.findAll(q). No filter params. The existing size param means "top-N limit" — this collides with the new pagination size and must be reconciled to one meaning (Markus, Elicit — see Open Decisions).
  • backend/.../person/PersonService.java:34-46findAll(q) returns findAllWithDocumentCount() (q null), List.of() when q.isBlank() (line 38-40 — the new filter path must NOT inherit this quirk; empty q + active filters returns filtered results, not empty), or searchWithDocumentCount(q).
  • backend/.../person/PersonSummaryDTO.java:10-22interface projection (getPersonType() returns String, not the enum — Felix). Backend filter comparisons must use the PersonType enum in the query; the codegen frontend type will be string.
  • backend/.../person/PersonRepository.java:40-86 — native queries use named parameters (no injection surface). findTopByDocumentCount ORDER BYs a computed alias and would "silently fail on H2" (note at :72-73). These native queries have no COUNT(*) companion.
  • backend/.../person/PersonController.java:102-111POST /api/persons/{id}/merge exists ({ "targetPersonId": "<uuid>" }, 204). mergePersons validates both ids with findById…orElseThrow (:184-186) and runs 4 native mutations. The triage view reuses this.
  • backend/.../person/Person.java:55-58 — has family_member; no provisional column today (comes from #671). PersonType includes UNKNOWN.
  • DELETE /api/persons/{id} — confirmed absent (review finding). The triage "Löschen" action needs it. Confirmed absent — adding DELETE /api/persons/{id} (@RequirePermission(WRITE_ALL)) is in scope here.

Dependency

Consumes Person.provisional (boolean) created by #671 and populated by #669. The "Needs review" toggle and the triage view key off that flag. Until #669 lands, fall back to the heuristic: default/show-all split uses familyMember || documentCount > 0; triage approximates provisional as documentCount == 0 && !familyMember (plus personType == UNKNOWN or a ?-only name). Interim caveat (Elicit): the "163 register + 942 provisional" split is only exact once the flag exists — pre-flag, a confirmed register person with zero documents is wrongly pushed into triage. Ship filtering + pagination + UI on the heuristic; wire the provisional param the moment the flag exists.

User journey

Reader (phone):

  1. Opens /persons. Sees only family members and people with at least one document — clean, paginated, ≤50 per page.
  2. Optionally narrows with filter chips: person type (Person / Group / Institution), family-only, has-documents.
  3. Never sees a ? initial or a wall of empty unconfirmed cards.

Transcriber (laptop/tablet):

  1. On /persons, flips the "Alle anzeigen / Zu prüfen" toggle to reveal provisional / zero-document / unconfirmed entries inline, or
  2. Opens the dedicated triage view listing only provisional persons, each with Merge / Umbenennen / Bestätigen / Löschen, and works through the import noise there.

Backend changes

PersonController.getPersons (PersonController.java:36-47)

Add filter params (all optional, backward-compatible). Bind type to the enum so Spring rejects garbage with 400 (Nora):

@RequestParam(required = false) PersonType type,       // PERSON | GROUP | INSTITUTION | UNKNOWN
@RequestParam(required = false) Boolean familyOnly,    // familyMember == true
@RequestParam(required = false) Boolean hasDocuments,  // documentCount > 0
@RequestParam(required = false) Boolean provisional    // Person.provisional (once #671/#669 land)
  • Pass through to PersonService.findAll(...). Reconcile the legacy size/sort=documentCount top-N branch into the new paged contract (it is effectively page=0&size=N&sort=documentCount) — do not leave two meanings of size (Markus, Elicit).
  • Server-side filtering only — never return all 1100 rows for the client to filter.

PersonService.findAll (PersonService.java:34-46)

  • Extract a small PersonFilter record (type, familyOnly, hasDocuments, provisional) rather than threading 4 params (Felix). Keeps the controller→service mapping one line.
  • Combine q + filters into the projection query in the repository (filter-aware native variants or a Specification/CriteriaBuilder approach) — not in-memory.
  • Empty q + active filters must return filtered results (not List.of()).
  • provisional maps to Person.provisional once the flag lands; until then, the documented heuristic.

Pagination — hand-written PersonSearchResult record (CORRECTED)

Correction (Markus). The prior draft cited Spring Page<T> and a PagedDocumentSearchResult. Neither is accurate. The real #315 precedent is a hand-written record document/DocumentSearchResult.java (items, totalElements, pageNumber, pageSize, totalPages). Spring Page<T> is deliberately avoided (unstable serialized shape across Spring versions, noisy in OpenAPI).

  • Create a new PersonSearchResult record in the person package, mirroring DocumentSearchResult's field shape exactly (items, totalElements, pageNumber, pageSize, totalPages). Do NOT import the document DTO (would couple two feature modules — duplication beats coupling) and do NOT use Spring Page<T>.
  • Response shape changes from List<PersonSummaryDTO> to PersonSearchResult.
  • Page/size params:
    @RequestParam(defaultValue = "0") @Min(0) int page,
    @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size,
    
  • size cap (@Max(100)) also caps a trivial resource-exhaustion vector (Nora). Frontend resets page to 0 on any filter change.

Server-side COUNT query — MANDATORY (CORRECTED)

Required work, not optional (Markus, Sara). The existing native person queries have no COUNT(*) companion. Without a count query that matches each filtered page query exactly, totalElements/totalPages are wrong and the AC "does not return all ~1100 rows" is unachievable.

  • Add a filter-aware paired count query (or derive both slice + count from one Specification).
  • Test against real Postgres via Testcontainers (postgres:16-alpine), NOT H2 — the native queries use Postgres-specific behavior (computed-alias ORDER BY silently fails on H2). A mocked-repo unit test cannot catch a slice/count mismatch.

Confirm endpoint — dedicated PATCH /api/persons/{id}/confirm (RESOLVED)

Unanimous across Markus, Felix, Nora — resolved. Clear provisional via a dedicated PATCH /api/persons/{id}/confirm, NOT a provisional field on PersonUpdateDTO. A mass-assignable boolean on the edit DTO is a mass-assignment risk (CWE-915) — it would be settable on update and create as a side effect. A dedicated verb is a clean state transition, trivially auditable, and cannot be mass-assigned.

  • @RequirePermission(Permission.WRITE_ALL).

Triage endpoints

  • Triage list reuses GET /api/persons?provisional=true&page=…&size=… — no new list endpoint.
  • Merge reuses POST /api/persons/{id}/merge ({ targetPersonId }).
  • Confirm = the PATCH /{id}/confirm above.
  • Rename reuses the existing update path.
  • Delete = DELETE /api/persons/{id}confirmed absent — adding DELETE /api/persons/{id} (@RequirePermission(WRITE_ALL)) is in scope here.

@RequirePermission

  • All read endpoints keep @RequirePermission(Permission.READ_ALL).
  • Every write endpoint (confirm, and delete if added) must carry @RequirePermission(Permission.WRITE_ALL).

OpenAPI regeneration

  • Run npm run generate:api in frontend/ after the response shape changes to PersonSearchResult and after any new param/endpoint. Commit the regenerated frontend/src/lib/generated/ artifact, or CI npm run check fails on type drift (Tobias).

Frontend changes

frontend/src/routes/persons/+page.server.ts

  • Read page (clamp ≥ 0), type, familyOnly, hasDocuments, and a review (show-all) flag from the URL.
  • Default (no review): restrict the load to familyMember || documentCount > 0. When review=1, drop the restriction (and set provisional appropriately once the flag exists).
  • Forward page + size=50 to api.GET('/api/persons', …).
  • Return { persons, totalElements, totalPages, pageNumber, pageSize, filters, review, needsReviewCount, q, canWrite }.

frontend/src/routes/persons/+page.svelte

Component boundaries (one nameable region each — Felix):

  • PersonFilterBar.svelte — filter chips (Person / Group / Institution, family-only, has-documents) + the "Alle anzeigen / Zu prüfen" toggle. Chips compose URL params via URLSearchParams, reset page=0 on change, use $derived for the active-filter set (never $effect to mirror URL into local $state).
  • PersonCard.svelte — extract the inline card (~75 lines, about to grow). Card fix: for provisional / UNKNOWN-type / ?-name persons render a neutral placeholder avatar (the Account-MD.svg glyph already used in PersonsEmptyState, on a muted non-primary background — visually distinct so "unverified" is pre-attentive) and an "unbestätigt" badge. Replace the line-132 initial fallback so a ? name never becomes a ? avatar (also fixes the null crash). Bump undersized metadata — life dates and doc-count chip are text-[11px]; raise to ≥12px, prefer text-sm for the 60+ audience (Leonie).
  • Reuse $lib/shared/primitives/Pagination.svelte as-is (already 44px controls, aria-current, sr-only "Seite X von Y" — do NOT rebuild). Import the canonical path, not a worktree copy (Tobias).

Triage view (transcriber path)

  • New route frontend/src/routes/persons/review/+page.svelte + +page.server.ts, loading GET /api/persons?provisional=true&page=…&size=….
  • Extract PersonReviewRow.svelte (do not grow merge/rename/confirm/delete inline). Each row: placeholder avatar, name (rename), doc count, actions Merge · Umbenennen · Bestätigen · Löschen:
    • Merge → person typeahead → POST /api/persons/{id}/merge { targetPersonId }.
    • BestätigenPATCH /api/persons/{id}/confirm.
    • Löschen → delete path behind a confirmation dialog (focus-trapped, Escape-dismissible — destructive for a 60+ transcriber).
  • canWrite UI gating is presentation only, NOT access control (Nora). The triage data is fetched under READ_ALL; readers can hit GET /api/persons?provisional=true directly. That is acceptable (writers are trusted), but every write action stays permission-guarded server-side — hiding the link is not security.
  • Reachable via a discreet "Zu prüfen (N)" link from /persons, shown only when canWrite.

Accessibility (Leonie)

  • Filter chips: <button aria-pressed> (or role="group" of toggles), ≥44px min height, visible focus-visible:ring-2 ring-brand-navy, keyboard-operable.
  • Show-all toggle: labeled switch (role="switch" + aria-checked) or paired aria-pressed buttons; the (N) count inside the accessible name, e.g. aria-label="Zu prüfen, 942".
  • "unbestätigt" badge: redundant cues — placeholder avatar (shape) + text label + aria-label, never color alone.
  • The provisional / "unbestätigt" badge must convey state via text or icon, not color alone (WCAG 1.4.1).
  • Triage action buttons: text label or aria-label, ≥44px targets.
  • Empty/loading states: render persons_empty_filtered and persons_review_empty as calm centered messages (match PersonsEmptyState); add a grid skeleton during navigation. Verify chips + toggle with axe in light AND dark mode before merge.

i18n — frontend/messages/{de,en,es}.json

Add keys (de shown; mirror for en/es):

persons_filter_type_person      → "Person"
persons_filter_type_group       → "Gruppe"
persons_filter_type_institution → "Institution"
persons_filter_family_only      → "Nur Familie"
persons_filter_has_documents    → "Mit Dokumenten"
persons_toggle_show_all         → "Alle anzeigen"
persons_toggle_needs_review     → "Zu prüfen ({count})"
person_badge_unconfirmed        → "unbestätigt"
persons_review_heading          → "Personen prüfen"
persons_review_action_merge     → "Zusammenführen"
persons_review_action_rename    → "Umbenennen"
persons_review_action_confirm   → "Bestätigen"
persons_review_action_delete    → "Löschen"
persons_review_empty            → "Keine Personen zu prüfen."
persons_empty_filtered          → "Keine Personen für diese Filter."

Reuse: persons_search_placeholder, persons_btn_new, person_type_*, person_card_doc_count_*, persons_empty_*, and #315's pagination keys (pagination_prev / pagination_next / pagination_page_of).

Doc updates this PR must carry (Markus — blockers)

  • New persons/review/ route → CLAUDE.md route table and docs/architecture/c4/l3-frontend-*-people-stories.puml.
  • New PersonSearchResult + confirm (and any delete) endpoint → docs/architecture/c4/l3-backend-*.puml (person domain).
  • The Person.provisional column diagram update (db-orm.puml, db-relationships.puml) belongs to #671, NOT here — do not duplicate.

Acceptance criteria

Feature: Clean, filterable, paginated persons directory

  Scenario: Reader sees only meaningful persons by default
    Given the archive has register persons and provisional persons
    When a reader opens "/persons" with no filter params
    Then the directory shows only persons where familyMember is true OR documentCount > 0
    And provisional / zero-document / "?" entries are not shown
    And at most 50 cards render on the first page

  Scenario: Pagination caps first paint with a real count
    Given more than 50 persons match the current filter
    When the directory loads
    Then at most 50 cards are rendered
    And totalElements equals the full filtered match count from the server-side COUNT query
    And a pagination control shows the current page and total pages
    And navigating to "?page=1" shows the next slice without losing the active filters

  Scenario: Page index beyond range
    When the directory is requested with "?page=999" beyond the last page
    Then the backend returns an empty slice with correct totalPages
    And it does not return a 500

  Scenario: Size bounds enforced
    When the directory is requested with size=0 or size=101
    Then the backend returns 400 (per @Min(1) @Max(100))

  Scenario: Filter chips narrow by type
    Given the directory is open
    When the reader activates the "Institution" type chip
    Then only persons with personType INSTITUTION are shown
    And the page resets to page 0
    And the active filter is reflected in the URL

  Scenario: Family-only and has-documents chips
    When the reader activates "Nur Familie"
    Then only familyMember persons are shown
    When the reader activates "Mit Dokumenten"
    Then only persons with documentCount > 0 are shown

  Scenario: Combined filters AND together
    When two chips are active
    Then only persons matching all active filters are returned

  Scenario: Show-all / Zu-prüfen toggle reveals the noise
    Given the reader view hides provisional persons
    When a transcriber enables the "Alle anzeigen" toggle
    Then provisional, zero-document, and unconfirmed persons also appear
    And the toggle state is reflected in the URL

  Scenario: Provisional cards never show a "?" avatar (and never crash on null lastName)
    Given a provisional person whose name is "?" or whose lastName is null
    When its card renders
    Then a neutral placeholder avatar is shown instead of a "?" initial
    And no render error is thrown
    And an "unbestätigt" badge with an aria-label is displayed on the card

  Scenario: Server-side filtering
    When the directory requests "/api/persons" with filter params
    Then the backend returns only the matching, paged subset
    And it does not return all rows for the client to filter

  Scenario: Transcriber triages provisional persons
    Given a transcriber with WRITE_ALL permission
    When they open the persons review (triage) view
    Then only provisional persons are listed
    And each row offers Merge, Umbenennen, Bestätigen, and Löschen actions

  Scenario: Merge from triage
    Given two provisional persons that are the same individual
    When the transcriber merges one into the other via the triage view
    Then "POST /api/persons/{id}/merge" is called with the target person id
    And the merged-away person no longer appears in the list
    And the surviving person retains the document references

  Scenario: Confirm clears provisional state and returns the person to readers
    Given a provisional person in the triage view
    When the transcriber confirms it via PATCH /api/persons/{id}/confirm
    Then the person is no longer provisional
    And it appears in the default reader directory afterwards

  Scenario: Confirm endpoint is permission-guarded
    Given a user with only READ_ALL permission
    When they call PATCH /api/persons/{id}/confirm
    Then the response is 403
    And an unauthenticated call returns 401

  Scenario: Hiding the triage link is not access control
    Given a user without WRITE_ALL permission
    When they browse "/persons"
    Then no "Zu prüfen" link or triage view is shown to them
    But the read API may still serve provisional rows on direct request (writers are trusted)

  Scenario: Empty states
    When the active filters match zero persons
    Then the "persons_empty_filtered" state renders
    And when the triage list is empty the "persons_review_empty" state renders

  Scenario: i18n coverage
    Then all filter labels, the toggle, the "unbestätigt" badge,
    and the triage action labels have de, en, and es translations

Tests

Backend (integration-first for query logic — Sara)

  • @DataJpaTest + Testcontainers postgres:16-alpine (NEVER H2): filter-aware slice + paired COUNT queries; totalElements matches the filtered count exactly; type/familyOnly/hasDocuments/provisional each return only matching rows; combined filters AND; default = familyMember || documentCount > 0; page=999 → empty slice + correct totalPages (not 500); merge round-trip (merged-away person gone, document refs survive — extend existing coverage, don't duplicate); confirm flips provisional and the person then appears in the default reader query.
  • PersonControllerTest (@WebMvcTest): page=0&size=50 → ≤50 items; filter params reach the service (argument captor); size=0/size=101 → 400; confirm endpoint → 403 (READ_ALL only) AND 401 (unauthenticated) as permanent fixtures.

Frontend

  • +page.server.ts loader: default returns only familyMember || documentCount > 0; review=1 drops the restriction; filter + page params forwarded to the API client.
  • PersonFilterBar component: activating a chip composes the URL via URLSearchParams, resets page to 0, preserves other params.
  • PersonCard badge: a ?-name / UNKNOWN / provisional / null-lastName person renders the placeholder avatar + "unbestätigt" badge, never a ? initial, never crashes.
  • Default-vs-show-all: provisional cards hidden by default, shown with the toggle on.
  • Triage view: rows render Merge/Rename/Confirm/Delete; merge calls POST /api/persons/{id}/merge with the chosen target.
  • E2E (CI only — hold to two journeys, Tobias/Sara): one reader-default journey + one transcriber-triage journey. No combinatorial chip matrix in Playwright.

Open Decisions

  1. Paged response field naming. RESOLVED toward #315 in this body: PersonSearchResult uses items / totalElements / pageNumber / pageSize / totalPages (match DocumentSearchResult for one paged shape across the app). Confirm before implementation; the alternative (Spring-style content/number) leaves two divergent shapes forever. (Markus)
  2. "Zu prüfen (N)" count source. Is {count} the totalElements of a provisional=true query, or a new field on /api/stats? This determines whether the /persons load fires an extra request. Tentative: surface needsReviewCount from the loader (cheap count call) rather than overloading /api/stats. (Elicit)
  3. Do filter chips AND with the reader default, or replace it? The Gherkin reads as "replace" (Institution chip → only INSTITUTION), but replacing the familyMember || documentCount > 0 default lets a zero-document provisional institution surface in the reader view — re-introducing the noise this feature removes. Alternative: chips AND within the clean default unless review=1 is set. Decide deliberately; it changes what a reader can stumble into. (Elicit, Markus)
  4. Are provisional / zero-document persons reachable via the read API by READ_ALL users at all? Tentative: leave readable (matches "all family members are trusted," simplest); the UI just hides the link. Alternative: require WRITE_ALL to pass review=1/provisional=true (a second check on a read path, divergence from "readers see everything, just can't edit"). (Nora)

Out of scope

  • The import-side classification that decides which persons are provisional (#669) and the Person.provisional schema/flag + migration itself (#671) — this issue only consumes the flag.
  • Date handling / life-date formatting changes.
  • Briefwechsel — being removed; wire nothing to it.
  • Bulk-merge / auto-dedup heuristics for triage (manual one-by-one merge only for now).
  • Observability: no requirement to log triage actions beyond whatever the reused merge/delete endpoints already audit.
> **⚠️ Phase 5 of the persons-import pipeline. This issue adds NO migration.** The `Person.provisional` and `source_ref` columns are created by **Phase 2 — #671** and *populated* by **Phase 3 — #669**. This issue only *consumes* the `provisional` flag for filtering and triage. **Dependency chain: #671 → #669 → this issue.** Do not add a Flyway migration here; do not assume the column exists at build time (a heuristic fallback is specified below for the interim). > This body was rewritten to fold in the seven-persona review (Markus, Felix, Nora, Elicit, Sara, Leonie, Tobias) and the Decision Queue. Material corrections vs. the prior draft are called out inline. --- ## Context After the spreadsheet import, `/persons` is a flat grid of **~1100 cards**: ~163 curated register persons **plus ~942 auto-generated provisional persons** (single first-names, group labels, literal `?` placeholders, `UNKNOWN`-type rows, zero-document entries). Everything renders in one undifferentiated grid with no type filter, no pagination, and no separation between "real" register entries and import noise. Readers — who just want to browse — are buried under question-mark and zero-document rows. Two audiences (see device-split context): - **Transcribers** (older, 60+, on laptop/tablet) do the triage work: confirming, renaming, merging, and deleting provisional entries. - **Readers** (younger, on phones) just browse the people who actually matter. The fix: make the **default directory view clean for readers** (family members and people with documents), push the messy triage behind an explicit toggle, paginate the grid server-side, and give transcribers a **dedicated triage surface** to clean up the import noise. The reader-facing filter data is **already on the wire** — `PersonSummaryDTO` exposes `getPersonType()`, `isFamilyMember()`, `getDocumentCount()`. No new summary fields are required for the reader filters; only `provisional` is added by the dependency chain. ### Current behaviour (verified against code) - `frontend/src/routes/persons/+page.server.ts:14-15` — loads `GET /api/persons` passing **only** `q`; line 30 returns the full `persons` array to the page. No type filter, no pagination, no slicing. - `frontend/src/routes/persons/+page.svelte:93` — flat grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` iterating **every** person (`{#each data.persons …}`, keyed on `person.id` — correct, keep). Cards show avatar, `PersonTypeBadge`, alias, life dates, doc-count chip (only when `documentCount > 0`, line 158). - **`frontend/src/routes/persons/+page.svelte:132` — latent null-avatar crash (Felix).** The fallback is `{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}`. If `lastName` is `null`/empty (a malformed provisional row), `person.lastName[0]` **throws at render**. The placeholder-avatar fix below also removes this crash — it is a **bug**, not polish. - `frontend/src/routes/persons/+page.svelte:21-26` — the debounced search `goto(\`/persons?q=${q}\`)` **drops every other param** (Felix). Reusing it naively for chips would wipe `page`/`type`/`review` on each keystroke. All navigation must compose params via `URLSearchParams` / `page.url`, never string concatenation. - `backend/.../person/PersonController.java:36-47` — `getPersons(q, size, sort)`. Has a top-N `documentCount` path (`sort=documentCount`, `size>0`, `q==null` → `findTopByDocumentCount`); otherwise `personService.findAll(q)`. **No filter params.** The existing `size` param means "top-N limit" — this collides with the new pagination `size` and **must be reconciled to one meaning** (Markus, Elicit — see Open Decisions). - `backend/.../person/PersonService.java:34-46` — `findAll(q)` returns `findAllWithDocumentCount()` (q null), **`List.of()` when `q.isBlank()`** (line 38-40 — the new filter path must NOT inherit this quirk; empty `q` + active filters returns filtered results, not empty), or `searchWithDocumentCount(q)`. - `backend/.../person/PersonSummaryDTO.java:10-22` — **interface projection** (`getPersonType()` returns `String`, not the enum — Felix). Backend filter comparisons must use the `PersonType` enum in the query; the codegen frontend type will be `string`. - `backend/.../person/PersonRepository.java:40-86` — native queries use named parameters (no injection surface). `findTopByDocumentCount` ORDER BYs a computed alias and would "silently fail on H2" (note at :72-73). **These native queries have no `COUNT(*)` companion.** - `backend/.../person/PersonController.java:102-111` — `POST /api/persons/{id}/merge` exists (`{ "targetPersonId": "<uuid>" }`, 204). `mergePersons` validates both ids with `findById…orElseThrow` (:184-186) and runs 4 native mutations. The triage view reuses this. - `backend/.../person/Person.java:55-58` — has `family_member`; **no `provisional` column today** (comes from #671). `PersonType` includes `UNKNOWN`. - **`DELETE /api/persons/{id}` — confirmed absent (review finding).** The triage "Löschen" action needs it. **Confirmed absent — adding `DELETE /api/persons/{id}` (`@RequirePermission(WRITE_ALL)`) is in scope here.** ## Dependency Consumes `Person.provisional` (boolean) created by **#671** and populated by **#669**. The **"Needs review" toggle and the triage view key off that flag**. Until #669 lands, fall back to the heuristic: default/show-all split uses `familyMember || documentCount > 0`; triage approximates provisional as `documentCount == 0 && !familyMember` (plus `personType == UNKNOWN` or a `?`-only name). **Interim caveat (Elicit):** the "163 register + 942 provisional" split is only *exact* once the flag exists — pre-flag, a confirmed register person with zero documents is wrongly pushed into triage. Ship filtering + pagination + UI on the heuristic; wire the `provisional` param the moment the flag exists. ## User journey **Reader (phone):** 1. Opens `/persons`. Sees only **family members and people with at least one document** — clean, paginated, ≤50 per page. 2. Optionally narrows with filter chips: person type (Person / Group / Institution), family-only, has-documents. 3. Never sees a `?` initial or a wall of empty unconfirmed cards. **Transcriber (laptop/tablet):** 1. On `/persons`, flips the **"Alle anzeigen / Zu prüfen"** toggle to reveal provisional / zero-document / unconfirmed entries inline, or 2. Opens the dedicated **triage view** listing only provisional persons, each with **Merge / Umbenennen / Bestätigen / Löschen**, and works through the import noise there. --- ## Backend changes ### `PersonController.getPersons` (`PersonController.java:36-47`) Add filter params (all optional, backward-compatible). **Bind `type` to the enum** so Spring rejects garbage with 400 (Nora): ```java @RequestParam(required = false) PersonType type, // PERSON | GROUP | INSTITUTION | UNKNOWN @RequestParam(required = false) Boolean familyOnly, // familyMember == true @RequestParam(required = false) Boolean hasDocuments, // documentCount > 0 @RequestParam(required = false) Boolean provisional // Person.provisional (once #671/#669 land) ``` - Pass through to `PersonService.findAll(...)`. **Reconcile the legacy `size`/`sort=documentCount` top-N branch into the new paged contract** (it is effectively `page=0&size=N&sort=documentCount`) — do not leave two meanings of `size` (Markus, Elicit). - **Server-side filtering only** — never return all 1100 rows for the client to filter. ### `PersonService.findAll` (`PersonService.java:34-46`) - Extract a small **`PersonFilter` record** (`type`, `familyOnly`, `hasDocuments`, `provisional`) rather than threading 4 params (Felix). Keeps the controller→service mapping one line. - Combine `q` + filters into the projection query in the **repository** (filter-aware native variants or a Specification/CriteriaBuilder approach) — not in-memory. - Empty `q` + active filters must return filtered results (not `List.of()`). - `provisional` maps to `Person.provisional` once the flag lands; until then, the documented heuristic. ### Pagination — hand-written `PersonSearchResult` record (CORRECTED) > **Correction (Markus).** The prior draft cited Spring `Page<T>` and a `PagedDocumentSearchResult`. Neither is accurate. The real #315 precedent is a **hand-written record** `document/DocumentSearchResult.java` (`items`, `totalElements`, `pageNumber`, `pageSize`, `totalPages`). Spring `Page<T>` is deliberately avoided (unstable serialized shape across Spring versions, noisy in OpenAPI). - **Create a new `PersonSearchResult` record in the `person` package**, mirroring `DocumentSearchResult`'s field shape **exactly** (`items`, `totalElements`, `pageNumber`, `pageSize`, `totalPages`). **Do NOT import the `document` DTO** (would couple two feature modules — duplication beats coupling) and **do NOT use Spring `Page<T>`**. - Response shape changes from `List<PersonSummaryDTO>` to `PersonSearchResult`. - Page/size params: ```java @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size, ``` - `size` cap (`@Max(100)`) also caps a trivial resource-exhaustion vector (Nora). Frontend resets `page` to 0 on any filter change. ### Server-side COUNT query — MANDATORY (CORRECTED) > **Required work, not optional (Markus, Sara).** The existing native person queries have **no `COUNT(*)` companion**. Without a count query that matches each filtered page query *exactly*, `totalElements`/`totalPages` are wrong and the AC "does not return all ~1100 rows" is unachievable. - Add a filter-aware paired count query (or derive both slice + count from one Specification). - **Test against real Postgres via Testcontainers (`postgres:16-alpine`), NOT H2** — the native queries use Postgres-specific behavior (computed-alias ORDER BY silently fails on H2). A mocked-repo unit test cannot catch a slice/count mismatch. ### Confirm endpoint — dedicated `PATCH /api/persons/{id}/confirm` (RESOLVED) > **Unanimous across Markus, Felix, Nora — resolved.** Clear `provisional` via a **dedicated `PATCH /api/persons/{id}/confirm`**, NOT a `provisional` field on `PersonUpdateDTO`. A mass-assignable boolean on the edit DTO is a **mass-assignment risk (CWE-915)** — it would be settable on update *and* create as a side effect. A dedicated verb is a clean state transition, trivially auditable, and cannot be mass-assigned. - `@RequirePermission(Permission.WRITE_ALL)`. ### Triage endpoints - Triage **list** reuses `GET /api/persons?provisional=true&page=…&size=…` — no new list endpoint. - **Merge** reuses `POST /api/persons/{id}/merge` (`{ targetPersonId }`). - **Confirm** = the `PATCH /{id}/confirm` above. - **Rename** reuses the existing update path. - **Delete** = `DELETE /api/persons/{id}` — **confirmed absent — adding `DELETE /api/persons/{id}` (`@RequirePermission(WRITE_ALL)`) is in scope here.** ### `@RequirePermission` - All read endpoints keep `@RequirePermission(Permission.READ_ALL)`. - Every write endpoint (confirm, and delete if added) **must** carry `@RequirePermission(Permission.WRITE_ALL)`. ### OpenAPI regeneration - Run `npm run generate:api` in `frontend/` after the response shape changes to `PersonSearchResult` and after any new param/endpoint. **Commit the regenerated `frontend/src/lib/generated/` artifact**, or CI `npm run check` fails on type drift (Tobias). --- ## Frontend changes ### `frontend/src/routes/persons/+page.server.ts` - Read `page` (clamp ≥ 0), `type`, `familyOnly`, `hasDocuments`, and a `review` (show-all) flag from the URL. - **Default (no `review`): restrict the load to `familyMember || documentCount > 0`.** When `review=1`, drop the restriction (and set `provisional` appropriately once the flag exists). - Forward `page` + `size=50` to `api.GET('/api/persons', …)`. - Return `{ persons, totalElements, totalPages, pageNumber, pageSize, filters, review, needsReviewCount, q, canWrite }`. ### `frontend/src/routes/persons/+page.svelte` Component boundaries (one nameable region each — Felix): - **`PersonFilterBar.svelte`** — filter chips (Person / Group / Institution, family-only, has-documents) + the "Alle anzeigen / Zu prüfen" toggle. Chips compose URL params via `URLSearchParams`, reset `page=0` on change, use `$derived` for the active-filter set (never `$effect` to mirror URL into local `$state`). - **`PersonCard.svelte`** — extract the inline card (~75 lines, about to grow). **Card fix:** for provisional / `UNKNOWN`-type / `?`-name persons render a **neutral placeholder avatar** (the `Account-MD.svg` glyph already used in `PersonsEmptyState`, on a muted non-primary background — visually distinct so "unverified" is pre-attentive) and an **"unbestätigt"** badge. Replace the line-132 initial fallback so a `?` name never becomes a `?` avatar (also fixes the null crash). **Bump undersized metadata** — life dates and doc-count chip are `text-[11px]`; raise to ≥12px, prefer `text-sm` for the 60+ audience (Leonie). - Reuse **`$lib/shared/primitives/Pagination.svelte`** as-is (already 44px controls, `aria-current`, sr-only "Seite X von Y" — do NOT rebuild). Import the canonical path, not a worktree copy (Tobias). ### Triage view (transcriber path) - New route `frontend/src/routes/persons/review/+page.svelte` + `+page.server.ts`, loading `GET /api/persons?provisional=true&page=…&size=…`. - Extract **`PersonReviewRow.svelte`** (do not grow merge/rename/confirm/delete inline). Each row: placeholder avatar, name (rename), doc count, actions **Merge · Umbenennen · Bestätigen · Löschen**: - **Merge** → person typeahead → `POST /api/persons/{id}/merge` `{ targetPersonId }`. - **Bestätigen** → `PATCH /api/persons/{id}/confirm`. - **Löschen** → delete path behind a **confirmation dialog** (focus-trapped, Escape-dismissible — destructive for a 60+ transcriber). - **`canWrite` UI gating is presentation only, NOT access control (Nora).** The triage data is fetched under `READ_ALL`; readers *can* hit `GET /api/persons?provisional=true` directly. That is acceptable (writers are trusted), but every write action stays permission-guarded server-side — hiding the link is not security. - Reachable via a discreet "Zu prüfen (N)" link from `/persons`, shown only when `canWrite`. ### Accessibility (Leonie) - **Filter chips:** `<button aria-pressed>` (or `role="group"` of toggles), **≥44px** min height, visible `focus-visible:ring-2 ring-brand-navy`, keyboard-operable. - **Show-all toggle:** labeled switch (`role="switch"` + `aria-checked`) or paired `aria-pressed` buttons; the `(N)` count inside the accessible name, e.g. `aria-label="Zu prüfen, 942"`. - **"unbestätigt" badge:** redundant cues — placeholder avatar (shape) + text label + `aria-label`, never color alone. - The provisional / "unbestätigt" badge must convey state via text or icon, not color alone (WCAG 1.4.1). - **Triage action buttons:** text label or `aria-label`, **≥44px** targets. - **Empty/loading states:** render `persons_empty_filtered` and `persons_review_empty` as calm centered messages (match `PersonsEmptyState`); add a grid skeleton during navigation. Verify chips + toggle with axe in light AND dark mode before merge. ### i18n — `frontend/messages/{de,en,es}.json` Add keys (de shown; mirror for en/es): ``` persons_filter_type_person → "Person" persons_filter_type_group → "Gruppe" persons_filter_type_institution → "Institution" persons_filter_family_only → "Nur Familie" persons_filter_has_documents → "Mit Dokumenten" persons_toggle_show_all → "Alle anzeigen" persons_toggle_needs_review → "Zu prüfen ({count})" person_badge_unconfirmed → "unbestätigt" persons_review_heading → "Personen prüfen" persons_review_action_merge → "Zusammenführen" persons_review_action_rename → "Umbenennen" persons_review_action_confirm → "Bestätigen" persons_review_action_delete → "Löschen" persons_review_empty → "Keine Personen zu prüfen." persons_empty_filtered → "Keine Personen für diese Filter." ``` Reuse: `persons_search_placeholder`, `persons_btn_new`, `person_type_*`, `person_card_doc_count_*`, `persons_empty_*`, and #315's pagination keys (`pagination_prev` / `pagination_next` / `pagination_page_of`). ### Doc updates this PR must carry (Markus — blockers) - New `persons/review/` route → `CLAUDE.md` route table **and** `docs/architecture/c4/l3-frontend-*-people-stories.puml`. - New `PersonSearchResult` + confirm (and any delete) endpoint → `docs/architecture/c4/l3-backend-*.puml` (person domain). - The `Person.provisional` **column** diagram update (`db-orm.puml`, `db-relationships.puml`) belongs to **#671**, NOT here — do not duplicate. --- ## Acceptance criteria ```gherkin Feature: Clean, filterable, paginated persons directory Scenario: Reader sees only meaningful persons by default Given the archive has register persons and provisional persons When a reader opens "/persons" with no filter params Then the directory shows only persons where familyMember is true OR documentCount > 0 And provisional / zero-document / "?" entries are not shown And at most 50 cards render on the first page Scenario: Pagination caps first paint with a real count Given more than 50 persons match the current filter When the directory loads Then at most 50 cards are rendered And totalElements equals the full filtered match count from the server-side COUNT query And a pagination control shows the current page and total pages And navigating to "?page=1" shows the next slice without losing the active filters Scenario: Page index beyond range When the directory is requested with "?page=999" beyond the last page Then the backend returns an empty slice with correct totalPages And it does not return a 500 Scenario: Size bounds enforced When the directory is requested with size=0 or size=101 Then the backend returns 400 (per @Min(1) @Max(100)) Scenario: Filter chips narrow by type Given the directory is open When the reader activates the "Institution" type chip Then only persons with personType INSTITUTION are shown And the page resets to page 0 And the active filter is reflected in the URL Scenario: Family-only and has-documents chips When the reader activates "Nur Familie" Then only familyMember persons are shown When the reader activates "Mit Dokumenten" Then only persons with documentCount > 0 are shown Scenario: Combined filters AND together When two chips are active Then only persons matching all active filters are returned Scenario: Show-all / Zu-prüfen toggle reveals the noise Given the reader view hides provisional persons When a transcriber enables the "Alle anzeigen" toggle Then provisional, zero-document, and unconfirmed persons also appear And the toggle state is reflected in the URL Scenario: Provisional cards never show a "?" avatar (and never crash on null lastName) Given a provisional person whose name is "?" or whose lastName is null When its card renders Then a neutral placeholder avatar is shown instead of a "?" initial And no render error is thrown And an "unbestätigt" badge with an aria-label is displayed on the card Scenario: Server-side filtering When the directory requests "/api/persons" with filter params Then the backend returns only the matching, paged subset And it does not return all rows for the client to filter Scenario: Transcriber triages provisional persons Given a transcriber with WRITE_ALL permission When they open the persons review (triage) view Then only provisional persons are listed And each row offers Merge, Umbenennen, Bestätigen, and Löschen actions Scenario: Merge from triage Given two provisional persons that are the same individual When the transcriber merges one into the other via the triage view Then "POST /api/persons/{id}/merge" is called with the target person id And the merged-away person no longer appears in the list And the surviving person retains the document references Scenario: Confirm clears provisional state and returns the person to readers Given a provisional person in the triage view When the transcriber confirms it via PATCH /api/persons/{id}/confirm Then the person is no longer provisional And it appears in the default reader directory afterwards Scenario: Confirm endpoint is permission-guarded Given a user with only READ_ALL permission When they call PATCH /api/persons/{id}/confirm Then the response is 403 And an unauthenticated call returns 401 Scenario: Hiding the triage link is not access control Given a user without WRITE_ALL permission When they browse "/persons" Then no "Zu prüfen" link or triage view is shown to them But the read API may still serve provisional rows on direct request (writers are trusted) Scenario: Empty states When the active filters match zero persons Then the "persons_empty_filtered" state renders And when the triage list is empty the "persons_review_empty" state renders Scenario: i18n coverage Then all filter labels, the toggle, the "unbestätigt" badge, and the triage action labels have de, en, and es translations ``` --- ## Tests **Backend (integration-first for query logic — Sara)** - **`@DataJpaTest` + Testcontainers `postgres:16-alpine` (NEVER H2):** filter-aware slice + paired COUNT queries; `totalElements` matches the filtered count exactly; `type`/`familyOnly`/`hasDocuments`/`provisional` each return only matching rows; combined filters AND; default = `familyMember || documentCount > 0`; `page=999` → empty slice + correct `totalPages` (not 500); merge round-trip (merged-away person gone, document refs survive — extend existing coverage, don't duplicate); confirm flips `provisional` and the person then appears in the default reader query. - `PersonControllerTest` (`@WebMvcTest`): `page=0&size=50` → ≤50 items; filter params reach the service (argument captor); `size=0`/`size=101` → 400; confirm endpoint → **403 (READ_ALL only) AND 401 (unauthenticated)** as permanent fixtures. **Frontend** - `+page.server.ts` loader: default returns only `familyMember || documentCount > 0`; `review=1` drops the restriction; filter + `page` params forwarded to the API client. - `PersonFilterBar` component: activating a chip composes the URL via `URLSearchParams`, resets page to 0, preserves other params. - `PersonCard` badge: a `?`-name / `UNKNOWN` / provisional / null-lastName person renders the placeholder avatar + "unbestätigt" badge, never a `?` initial, never crashes. - Default-vs-show-all: provisional cards hidden by default, shown with the toggle on. - Triage view: rows render Merge/Rename/Confirm/Delete; merge calls `POST /api/persons/{id}/merge` with the chosen target. - E2E (CI only — hold to two journeys, Tobias/Sara): one reader-default journey + one transcriber-triage journey. No combinatorial chip matrix in Playwright. --- ## Open Decisions 1. **Paged response field naming.** RESOLVED toward #315 in this body: `PersonSearchResult` uses `items` / `totalElements` / `pageNumber` / `pageSize` / `totalPages` (match `DocumentSearchResult` for one paged shape across the app). Confirm before implementation; the alternative (Spring-style `content`/`number`) leaves two divergent shapes forever. _(Markus)_ 2. **"Zu prüfen (N)" count source.** Is `{count}` the `totalElements` of a `provisional=true` query, or a new field on `/api/stats`? This determines whether the `/persons` load fires an extra request. Tentative: surface `needsReviewCount` from the loader (cheap count call) rather than overloading `/api/stats`. _(Elicit)_ 3. **Do filter chips AND with the reader default, or replace it?** The Gherkin reads as "replace" (Institution chip → only INSTITUTION), but replacing the `familyMember || documentCount > 0` default lets a zero-document provisional institution surface in the *reader* view — re-introducing the noise this feature removes. Alternative: chips AND within the clean default unless `review=1` is set. Decide deliberately; it changes what a reader can stumble into. _(Elicit, Markus)_ 4. **Are provisional / zero-document persons reachable via the read API by `READ_ALL` users at all?** Tentative: leave readable (matches "all family members are trusted," simplest); the UI just hides the link. Alternative: require `WRITE_ALL` to pass `review=1`/`provisional=true` (a second check on a read path, divergence from "readers see everything, just can't edit"). _(Nora)_ --- ## Out of scope - The **import-side classification** that decides which persons are provisional (#669) and the `Person.provisional` schema/flag + migration itself (#671) — this issue only *consumes* the flag. - Date handling / life-date formatting changes. - **Briefwechsel** — being removed; wire nothing to it. - Bulk-merge / auto-dedup heuristics for triage (manual one-by-one merge only for now). - Observability: no requirement to log triage actions beyond whatever the reused merge/delete endpoints already audit.
marcel added the P2-mediumfeaturepersonui labels 2026-05-26 20:33:41 +02:00
marcel added this to the Handling the Unknowns — honest uncertainty in dates & people milestone 2026-05-26 20:35:02 +02:00
Author
Owner

Architect — Markus Keller (@mkeller)

Observations

  • The corrected pagination design is sound. I verified document/DocumentSearchResult.java is exactly the hand-written record this issue cites (items, totalElements, pageNumber, pageSize, totalPages) and that DocumentController.search already binds @RequestParam @Min(0) @Max(100_000) int page / @Min(1) @Max(100) int size. Mirroring that shape into a new PersonSearchResult (not importing the document DTO, not Spring Page<T>) is the correct, boundary-respecting choice. Duplication beats coupling here — agreed.
  • Stronger precedent than the issue names for the filter mechanism: the document domain does filtered, paged, counted search via DocumentSpecifications + JpaSpecificationExecutor (findAll(spec, pageable) gives you the slice AND count(spec) for totalElements from one Specification). PersonSummaryDTO is an interface projection over a native query, so the Specification path isn't a drop-in — but the issue's "Specification/CriteriaBuilder approach" alternative is the architecturally cleaner one because it gives the paired COUNT for free and eliminates the "native query with no COUNT companion" risk the issue correctly flags. If you stay native, the slice query and the COUNT query MUST share one WHERE-clause builder so they can never drift.
  • The legacy size/sort=documentCount top-N branch in PersonController.getPersons (lines 42-45, Math.min(size, 50)) genuinely collides with the new paged size. Verified — this is real, not theoretical. Two meanings of one param is exactly the kind of silent coupling that rots a controller.
  • The dedicated PATCH /{id}/confirm over a DTO field is the right state-transition modeling. Confirm/merge/delete reusing existing verbs keeps the triage view from spawning a parallel API surface.

Recommendations

  • Reconcile size to one meaning. The legacy top-N is findTopByDocumentCount(safeSize). Express it as page=0, size=N, sort=documentCount within the new paged contract and delete the special-case branch. One param, one meaning. If the dashboard is the only caller of the old top-N path, confirm that and migrate it in the same PR so you don't leave a dead branch.
  • Build the slice and count from a single source of truth. Whether Specification or twin native queries, there must be exactly one place that encodes "type AND familyOnly AND hasDocuments AND provisional AND q". A mismatched COUNT is the classic pagination bug.
  • Doc currency is a merge blocker (per my doc-update table): new persons/review/ route → CLAUDE.md route table + docs/architecture/c4/l3-frontend-*-people-stories.puml; new PersonSearchResult record + PATCH /confirm (+ DELETE if added) → docs/architecture/c4/l3-backend-*.puml person domain. The issue already lists these — good. The Person.provisional column diagram belongs to #671, NOT here; do not duplicate it.
  • This issue adds no migration — correct and important. The heuristic fallback (familyMember || documentCount > 0 for the default split) is the right interim until #669 populates the flag. Keep that logic in the service, not the controller.

Open Decisions

  • Paged response field naming — the body resolves toward items/totalElements/pageNumber/pageSize/totalPages to match DocumentSearchResult. I strongly endorse this; the only reason to revisit is if you ever intend to expose Spring Page shapes elsewhere (you don't). Confirm and move on — one paged shape across the app is worth defending.
  • Do filter chips AND with the reader default, or replace it? This is a genuine architecture-of-behavior decision, not a code question. If a type=INSTITUTION chip replaces the familyMember || documentCount > 0 default, a zero-document provisional institution leaks back into the reader view — re-introducing the exact noise this feature removes. ANDing chips within the clean default (unless review=1) preserves the invariant. State the intended semantics in the AC before Felix implements, because the Gherkin currently reads ambiguously.
## Architect — Markus Keller (@mkeller) ### Observations - The corrected pagination design is sound. I verified `document/DocumentSearchResult.java` is exactly the hand-written record this issue cites (`items`, `totalElements`, `pageNumber`, `pageSize`, `totalPages`) and that `DocumentController.search` already binds `@RequestParam @Min(0) @Max(100_000) int page` / `@Min(1) @Max(100) int size`. Mirroring that shape into a new `PersonSearchResult` (not importing the document DTO, not Spring `Page<T>`) is the correct, boundary-respecting choice. Duplication beats coupling here — agreed. - **Stronger precedent than the issue names for the filter mechanism:** the document domain does filtered, paged, counted search via `DocumentSpecifications` + `JpaSpecificationExecutor` (`findAll(spec, pageable)` gives you the slice AND `count(spec)` for `totalElements` from one Specification). `PersonSummaryDTO` is an *interface projection* over a native query, so the Specification path isn't a drop-in — but the issue's "Specification/CriteriaBuilder approach" alternative is the architecturally cleaner one because it gives the paired COUNT for free and eliminates the "native query with no COUNT companion" risk the issue correctly flags. If you stay native, the slice query and the COUNT query MUST share one WHERE-clause builder so they can never drift. - The legacy `size`/`sort=documentCount` top-N branch in `PersonController.getPersons` (lines 42-45, `Math.min(size, 50)`) genuinely collides with the new paged `size`. Verified — this is real, not theoretical. Two meanings of one param is exactly the kind of silent coupling that rots a controller. - The dedicated `PATCH /{id}/confirm` over a DTO field is the right state-transition modeling. Confirm/merge/delete reusing existing verbs keeps the triage view from spawning a parallel API surface. ### Recommendations - **Reconcile `size` to one meaning.** The legacy top-N is `findTopByDocumentCount(safeSize)`. Express it as `page=0, size=N, sort=documentCount` within the new paged contract and delete the special-case branch. One param, one meaning. If the dashboard is the only caller of the old top-N path, confirm that and migrate it in the same PR so you don't leave a dead branch. - **Build the slice and count from a single source of truth.** Whether Specification or twin native queries, there must be exactly one place that encodes "type AND familyOnly AND hasDocuments AND provisional AND q". A mismatched COUNT is the classic pagination bug. - **Doc currency is a merge blocker** (per my doc-update table): new `persons/review/` route → `CLAUDE.md` route table + `docs/architecture/c4/l3-frontend-*-people-stories.puml`; new `PersonSearchResult` record + `PATCH /confirm` (+ `DELETE` if added) → `docs/architecture/c4/l3-backend-*.puml` person domain. The issue already lists these — good. The `Person.provisional` column diagram belongs to #671, NOT here; do not duplicate it. - This issue adds **no migration** — correct and important. The heuristic fallback (`familyMember || documentCount > 0` for the default split) is the right interim until #669 populates the flag. Keep that logic in the service, not the controller. ### Open Decisions - **Paged response field naming** — the body resolves toward `items`/`totalElements`/`pageNumber`/`pageSize`/`totalPages` to match `DocumentSearchResult`. I strongly endorse this; the only reason to revisit is if you ever intend to expose Spring `Page` shapes elsewhere (you don't). Confirm and move on — one paged shape across the app is worth defending. - **Do filter chips AND with the reader default, or replace it?** This is a genuine architecture-of-behavior decision, not a code question. If a `type=INSTITUTION` chip *replaces* the `familyMember || documentCount > 0` default, a zero-document provisional institution leaks back into the reader view — re-introducing the exact noise this feature removes. ANDing chips within the clean default (unless `review=1`) preserves the invariant. State the intended semantics in the AC before Felix implements, because the Gherkin currently reads ambiguously.
Author
Owner

Developer — Felix Brandt (@felixbrandt)

Observations

  • The null-avatar crash is real and exactly as described. +page.svelte:132 is {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}. lastName is @Column(nullable = false) on the entity, but a provisional/import row or a future bad state makes person.lastName[0] throw at render. The placeholder-avatar fix removes the crash AND the ?-initial problem in one change. Good — treat it as a bug with a failing test, not polish.
  • The debounced search drops params: +page.svelte:24 does goto(\/persons?q=${q}`). Confirmed. Reusing this for chips would wipe page/type/review. Every navigation must compose via URLSearchParamsoverpage.url.searchParams, never template-string concatenation. Also note line 12-17 uses $state+$effectto mirrordata.qinto local state — that's the pattern to NOT replicate for filter state. Filter chips read from the URL via$derived; only the debounced text input needs local $state`.
  • PersonSummaryDTO is an interface projection returning String getPersonType() (not the enum). So in the codegen frontend type personType is string, and the backend filter comparison must use the PersonType enum bound on the controller param. The issue states this correctly.
  • +page.svelte is already ~172 lines with the card inline (~70 lines of it). It is over the splitting threshold now and about to grow. The component breakdown in the issue (PersonFilterBar, PersonCard, PersonReviewRow) is the correct set of one-nameable-region splits.

Recommendations

  • TDD order, red first every time:
    1. Backend @DataJpaTest (Testcontainers postgres:16-alpine) — write the failing filter+count test before the repository query exists. The mismatch between slice and COUNT only shows up against real Postgres.
    2. PersonControllerTest (@WebMvcTest) — size=0/size=101 → 400; confirm endpoint 403 (READ_ALL) AND 401 (unauthenticated). Argument-captor that filter params reach the service.
    3. +page.svelte:132 crash test (vitest-browser-svelte) — render a person with lastName: null / name "?", assert placeholder avatar + unbestätigt badge, no throw. Watch it go red against current code.
    4. PersonFilterBar — activating a chip composes the URL via URLSearchParams, resets page=0, preserves other params.
  • Extract a PersonFilter record (type, familyOnly, hasDocuments, provisional) for the controller→service hop. Threading four Boolean params plus an enum is a smell; a record keeps findAll(...) to one parameter and is @Builder-friendly for tests.
  • Reuse Pagination.svelte verbatim. I read it — 44px controls, sliding window, aria-current, sr-only "Seite X von Y". It takes page, totalPages, makeHref. makeHref is where you compose the URL preserving filters. Do NOT rebuild it; import $lib/shared/primitives/Pagination.svelte.
  • Pass domain-named props, never the whole data object. <PersonCard person={...} />, <PersonReviewRow person={...} canWrite={...} />. Keep {#each ... (person.id)} keyed (the current code already does — keep it).
  • Confirm/delete writes: check !result.response.ok, map errors via getErrorMessage(). Delete behind a focus-trapped confirm dialog.

Open Decisions

None. The component boundaries, the PersonSearchResult shape, and the confirm-endpoint approach are all decided correctly in the body. Naming and test layering are mine to execute, not decisions for the PO.

## Developer — Felix Brandt (@felixbrandt) ### Observations - The null-avatar crash is **real and exactly as described**. `+page.svelte:132` is `{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}`. `lastName` is `@Column(nullable = false)` on the entity, but a provisional/import row or a future bad state makes `person.lastName[0]` throw at render. The placeholder-avatar fix removes the crash AND the `?`-initial problem in one change. Good — treat it as a bug with a failing test, not polish. - The debounced search drops params: `+page.svelte:24` does `goto(\`/persons?q=${q}\`)`. Confirmed. Reusing this for chips would wipe `page`/`type`/`review`. Every navigation must compose via `URLSearchParams` over `page.url.searchParams`, never template-string concatenation. Also note line 12-17 uses `$state` + `$effect` to mirror `data.q` into local state — that's the pattern to NOT replicate for filter state. Filter chips read from the URL via `$derived`; only the debounced text input needs local `$state`. - `PersonSummaryDTO` is an interface projection returning `String getPersonType()` (not the enum). So in the codegen frontend type `personType` is `string`, and the *backend* filter comparison must use the `PersonType` enum bound on the controller param. The issue states this correctly. - `+page.svelte` is already ~172 lines with the card inline (~70 lines of it). It is over the splitting threshold now and about to grow. The component breakdown in the issue (`PersonFilterBar`, `PersonCard`, `PersonReviewRow`) is the correct set of one-nameable-region splits. ### Recommendations - **TDD order, red first every time:** 1. Backend `@DataJpaTest` (Testcontainers `postgres:16-alpine`) — write the failing filter+count test before the repository query exists. The mismatch between slice and COUNT only shows up against real Postgres. 2. `PersonControllerTest` (`@WebMvcTest`) — `size=0`/`size=101` → 400; confirm endpoint 403 (READ_ALL) AND 401 (unauthenticated). Argument-captor that filter params reach the service. 3. `+page.svelte:132` crash test (`vitest-browser-svelte`) — render a person with `lastName: null` / name `"?"`, assert placeholder avatar + `unbestätigt` badge, no throw. Watch it go red against current code. 4. `PersonFilterBar` — activating a chip composes the URL via `URLSearchParams`, resets `page=0`, preserves other params. - **Extract a `PersonFilter` record** (`type`, `familyOnly`, `hasDocuments`, `provisional`) for the controller→service hop. Threading four `Boolean` params plus an enum is a smell; a record keeps `findAll(...)` to one parameter and is `@Builder`-friendly for tests. - **Reuse `Pagination.svelte` verbatim.** I read it — 44px controls, sliding window, `aria-current`, sr-only "Seite X von Y". It takes `page`, `totalPages`, `makeHref`. `makeHref` is where you compose the URL preserving filters. Do NOT rebuild it; import `$lib/shared/primitives/Pagination.svelte`. - **Pass domain-named props, never the whole `data` object.** `<PersonCard person={...} />`, `<PersonReviewRow person={...} canWrite={...} />`. Keep `{#each ... (person.id)}` keyed (the current code already does — keep it). - Confirm/delete writes: check `!result.response.ok`, map errors via `getErrorMessage()`. Delete behind a focus-trapped confirm dialog. ### Open Decisions _None._ The component boundaries, the `PersonSearchResult` shape, and the confirm-endpoint approach are all decided correctly in the body. Naming and test layering are mine to execute, not decisions for the PO.
Author
Owner

Security — Nora Steiner / "NullX" (@nullx)

Observations

  • The PATCH /{id}/confirm decision correctly closes a mass-assignment vector (CWE-915). If provisional were a settable field on PersonUpdateDTO, it would be assignable on both PUT /{id} (update) and POST (create) via personService.createPerson(dto) / updatePerson(id, dto) — verified those methods copy DTO fields straight onto the entity. A dedicated verb is the right state transition and cannot be smuggled in through the edit form. Endorsed.
  • canWrite UI gating is presentation, not access control — the body says this and it's correct. The triage data is fetched under READ_ALL (GET /api/persons carries @RequirePermission(READ_ALL)). A READ_ALL user CAN hit ?provisional=true directly. That's acceptable here (writers/readers are all trusted family members) but the security boundary is the server-side @RequirePermission(WRITE_ALL) on every write verb — hiding the link is UX, not a control.
  • Binding type to the PersonType enum makes Spring reject garbage with 400 automatically — good input-validation-at-the-boundary. The new filter params feed into either a Specification or native query with named parameters; the existing person native queries already use :query named params (no injection surface). Keep it that way — no string concatenation into the new filter SQL.
  • The @Max(100) size cap also bounds a trivial resource-exhaustion vector (no size=100000 returning the whole table). Correct.

Recommendations

  • Permission tests are permanent fixtures, not nice-to-haves:
    • PATCH /{id}/confirm403 when caller has only READ_ALL, 401 when unauthenticated. These are different security failures; assert both.
    • If DELETE /api/persons/{id} is added (see below), it needs the identical 403/401 pair plus @RequirePermission(WRITE_ALL).
  • DELETE /api/persons/{id} does not exist today — I grepped the person package; only /{id}/aliases/{aliasId} and relationships/{relId} deletes exist. The triage "Löschen" action requires a new person-delete endpoint. When you add it: @RequirePermission(WRITE_ALL), DomainException.notFound(...) for a missing id (never Optional.get()), and decide cascade behaviour for a person that is still a document sender/receiver — a hard delete that orphans an FK is a data-integrity bug, not just a 500. Prefer a guard that refuses to delete a person with live document references, or document the cascade explicitly.
  • Keep error messages mapped through getErrorMessage(code) on the frontend — no raw backend strings in the triage UI.
  • New filter params on a READ_ALL endpoint do not expand the auth surface, but confirm no filter value can be reflected into a log line unsanitized (use SLF4J {} placeholders if you log the filter).

Open Decisions

  • Should provisional=true / review=1 be reachable by READ_ALL users at all? Tentative (body): leave readable — consistent with "all family members are trusted; readers just can't edit," and the simplest model (no second check on a read path). Alternative: gate review=1/provisional=true behind WRITE_ALL, which diverges from "readers see everything, just can't change it" and adds a permission check on a GET. This is a trust-model decision, not a vulnerability — pick the model deliberately and write the AC to match. The current AC ("the read API may still serve provisional rows on direct request") encodes the leave-readable choice; confirm that's intended.
## Security — Nora Steiner / "NullX" (@nullx) ### Observations - The `PATCH /{id}/confirm` decision correctly closes a **mass-assignment vector (CWE-915)**. If `provisional` were a settable field on `PersonUpdateDTO`, it would be assignable on both `PUT /{id}` (update) and `POST` (create) via `personService.createPerson(dto)` / `updatePerson(id, dto)` — verified those methods copy DTO fields straight onto the entity. A dedicated verb is the right state transition and cannot be smuggled in through the edit form. Endorsed. - **`canWrite` UI gating is presentation, not access control** — the body says this and it's correct. The triage data is fetched under `READ_ALL` (`GET /api/persons` carries `@RequirePermission(READ_ALL)`). A `READ_ALL` user CAN hit `?provisional=true` directly. That's acceptable here (writers/readers are all trusted family members) but the security boundary is the server-side `@RequirePermission(WRITE_ALL)` on every write verb — hiding the link is UX, not a control. - Binding `type` to the `PersonType` enum makes Spring reject garbage with 400 automatically — good input-validation-at-the-boundary. The new filter params feed into either a Specification or native query with named parameters; the existing person native queries already use `:query` named params (no injection surface). Keep it that way — no string concatenation into the new filter SQL. - The `@Max(100)` size cap also bounds a trivial resource-exhaustion vector (no `size=100000` returning the whole table). Correct. ### Recommendations - **Permission tests are permanent fixtures, not nice-to-haves:** - `PATCH /{id}/confirm` → **403** when caller has only `READ_ALL`, **401** when unauthenticated. These are different security failures; assert both. - If `DELETE /api/persons/{id}` is added (see below), it needs the identical 403/401 pair plus `@RequirePermission(WRITE_ALL)`. - **`DELETE /api/persons/{id}` does not exist today** — I grepped the person package; only `/{id}/aliases/{aliasId}` and `relationships/{relId}` deletes exist. The triage "Löschen" action requires a new person-delete endpoint. When you add it: `@RequirePermission(WRITE_ALL)`, `DomainException.notFound(...)` for a missing id (never `Optional.get()`), and decide cascade behaviour for a person that is still a document sender/receiver — a hard delete that orphans an FK is a data-integrity bug, not just a 500. Prefer a guard that refuses to delete a person with live document references, or document the cascade explicitly. - Keep error messages mapped through `getErrorMessage(code)` on the frontend — no raw backend strings in the triage UI. - New filter params on a `READ_ALL` endpoint do not expand the auth surface, but confirm no filter value can be reflected into a log line unsanitized (use SLF4J `{}` placeholders if you log the filter). ### Open Decisions - **Should `provisional=true` / `review=1` be reachable by `READ_ALL` users at all?** Tentative (body): leave readable — consistent with "all family members are trusted; readers just can't edit," and the simplest model (no second check on a read path). Alternative: gate `review=1`/`provisional=true` behind `WRITE_ALL`, which diverges from "readers see everything, just can't change it" and adds a permission check on a GET. This is a trust-model decision, not a vulnerability — pick the model deliberately and write the AC to match. The current AC ("the read API may still serve provisional rows on direct request") encodes the *leave-readable* choice; confirm that's intended.
Author
Owner

QA / Test Strategy — Sara Holt (@saraholt)

Observations

  • The test plan is genuinely strong: integration-first for query logic, Testcontainers postgres:16-alpine (never H2), and the explicit "a mocked-repo unit test cannot catch a slice/count mismatch" reasoning. That's the correct instinct — the existing person native queries even carry a comment that computed-alias ORDER BY documentCount "would silently fail on H2." H2 here would be false confidence.
  • The mandatory COUNT-query coverage is the highest-value test in this issue. totalElements driving totalPages driving the AC "does not return all ~1100 rows" — if the COUNT doesn't match the filtered slice's WHERE clause exactly, pagination lies and no UI test will catch it. Make the assertion totalElements == filtered-fixture-count for each filter permutation.
  • E2E held to two journeys (one reader-default, one transcriber-triage) is right. A combinatorial chip matrix belongs at the integration layer (load function + @DataJpaTest), not Playwright.
  • The backend coverage gate here is 88% branch (per backend/CLAUDE.md), not 80%. The new filter branches (each chip on/off, default vs review=1, page beyond range) are branch-heavy — budget tests for every branch or the gate blocks the merge.

Recommendations

  • Boundary fixtures to assert explicitly:
    • page=999 (beyond last page) → empty slice + correct totalPages, not 500.
    • size=0 and size=101 → 400 (@Min(1) @Max(100)).
    • Default load returns ONLY familyMember || documentCount > 0; a zero-document non-family person is absent.
    • review=1 drops the restriction and the same zero-document person now appears.
    • Combined filters AND (two chips → intersection, not union).
    • Confirm flips provisional and the person then appears in the default reader query (round-trip, one test).
    • Merge round-trip: extend the existing merge coverage, don't duplicate it — merged-away person gone, document refs survive.
  • Frontend +page.server.ts loader tests (plain TS, mock only fetch): default restriction applied; review=1 removes it; page+filter params forwarded to the API client.
  • PersonCard crash regression test is non-negotiablelastName: null / name "?" must render the placeholder + unbestätigt, never throw. This stays in the suite forever; it's a real production crash.
  • Keep tests at the right layer. Filter permutations → @DataJpaTest. Controller contract (status codes, param binding) → @WebMvcTest with argument captor. User-visible behaviour → vitest-browser-svelte. Only the two journeys → Playwright.
  • axe-core on /persons and /persons/review in both light and dark mode is a gate (coordinate with Leonie). The filter chips and the show-all switch are new interactive surfaces — they get axe coverage before merge.

Open Decisions

None. The test strategy is well-specified. My only ask is that the COUNT/slice parity assertion and the null-lastName crash test are treated as must-have permanent fixtures, not optional — and that's an execution standard, not a decision for the PO.

## QA / Test Strategy — Sara Holt (@saraholt) ### Observations - The test plan is genuinely strong: integration-first for query logic, Testcontainers `postgres:16-alpine` (never H2), and the explicit "a mocked-repo unit test cannot catch a slice/count mismatch" reasoning. That's the correct instinct — the existing person native queries even carry a comment that computed-alias `ORDER BY documentCount` "would silently fail on H2." H2 here would be false confidence. - The mandatory COUNT-query coverage is the highest-value test in this issue. `totalElements` driving `totalPages` driving the AC "does not return all ~1100 rows" — if the COUNT doesn't match the filtered slice's WHERE clause exactly, pagination lies and no UI test will catch it. Make the assertion `totalElements == filtered-fixture-count` for each filter permutation. - E2E held to two journeys (one reader-default, one transcriber-triage) is right. A combinatorial chip matrix belongs at the integration layer (load function + `@DataJpaTest`), not Playwright. - The backend coverage gate here is **88% branch** (per `backend/CLAUDE.md`), not 80%. The new filter branches (each chip on/off, default vs `review=1`, `page` beyond range) are branch-heavy — budget tests for every branch or the gate blocks the merge. ### Recommendations - **Boundary fixtures to assert explicitly:** - `page=999` (beyond last page) → empty slice + correct `totalPages`, **not 500**. - `size=0` and `size=101` → 400 (`@Min(1) @Max(100)`). - Default load returns ONLY `familyMember || documentCount > 0`; a zero-document non-family person is absent. - `review=1` drops the restriction and the same zero-document person now appears. - Combined filters AND (two chips → intersection, not union). - Confirm flips `provisional` and the person then appears in the default reader query (round-trip, one test). - Merge round-trip: **extend the existing merge coverage, don't duplicate it** — merged-away person gone, document refs survive. - **Frontend `+page.server.ts` loader tests** (plain TS, mock only `fetch`): default restriction applied; `review=1` removes it; `page`+filter params forwarded to the API client. - **`PersonCard` crash regression test is non-negotiable** — `lastName: null` / name `"?"` must render the placeholder + `unbestätigt`, never throw. This stays in the suite forever; it's a real production crash. - **Keep tests at the right layer.** Filter permutations → `@DataJpaTest`. Controller contract (status codes, param binding) → `@WebMvcTest` with argument captor. User-visible behaviour → `vitest-browser-svelte`. Only the two journeys → Playwright. - axe-core on `/persons` and `/persons/review` in **both light and dark mode** is a gate (coordinate with Leonie). The filter chips and the show-all switch are new interactive surfaces — they get axe coverage before merge. ### Open Decisions _None._ The test strategy is well-specified. My only ask is that the COUNT/slice parity assertion and the null-lastName crash test are treated as must-have permanent fixtures, not optional — and that's an execution standard, not a decision for the PO.
Author
Owner

UX / Accessibility — Leonie Voss (@leonievoss)

Observations

  • This issue is designing for the hardest constraint correctly: the 60+ transcriber on a laptop/tablet doing destructive triage, and the reader on a phone who must never hit a wall of ? cards. That framing is right.
  • Undersized metadata confirmed in code. +page.svelte:152 life dates are text-[11px] and :160 the doc-count chip is text-[11px]. 11px is below my 12px hard floor and is genuinely hard to read for the senior audience. Raise to ≥12px; prefer text-sm (14px) for life dates and the chip.
  • The reused Pagination.svelte is solid — I read it: min-h-[44px] min-w-[44px] controls, focus-visible:ring-2 focus-visible:ring-brand-navy, aria-current="page", an sr-only "Seite X von Y" that stays in the AT tree at every breakpoint, and ellipsis spans are aria-hidden. Reuse it verbatim; rebuilding would regress all of this.
  • The placeholder-avatar plan (neutral Account-MD.svg glyph on a muted non-primary background + unbestätigt badge) gives the redundant cues I require: shape + text, never color alone. Good — and it doubles as the crash fix.

Recommendations (exact values)

  • Filter chips: <button aria-pressed={active}>, min-h-[44px] min-w-[44px] px-4 py-2, focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none. Active state must differ by more than color — add a check glyph or a filled background plus the aria-pressed state. Group them in a role="group" with an aria-label ("Filter").
  • Show-all / Zu-prüfen toggle: a labeled role="switch" + aria-checked, OR paired aria-pressed buttons. Put the count inside the accessible name: aria-label="Zu prüfen, 942" — a bare "(942)" is announced as a meaningless number out of context.
  • unbestätigt badge: placeholder avatar (shape) + visible "unbestätigt" text + aria-label on the card. Use a token, not raw color, and verify contrast in dark mode — a muted badge background that passes AA in light mode can fail in dark.
  • Triage action buttons (Merge · Umbenennen · Bestätigen · Löschen): ≥44px targets, real text labels (not icon-only). "Löschen" is destructive for a 60+ user — confirmation dialog must be focus-trapped, Escape-dismissible, with focus returning to the trigger on close, and the confirm button clearly labeled ("Person löschen", not "OK").
  • States: render persons_empty_filtered and persons_review_empty as calm centered messages matching PersonsEmptyState; add a grid skeleton during navigation so the reader isn't staring at a blank page on a slow phone.
  • Card avatar block is currently bg-primary with text-primary-fg initials — the provisional placeholder must be visually distinct (muted, non-primary) so "unverified" is pre-attentive, not something you have to read to discover.
  • Test gate: axe-core on /persons and /persons/review in light AND dark mode; visual regression at 320 / 768 / 1440px before merge. Chips and toggle wrap, not overflow, at 320px.

Open Decisions

None from my side. The accessibility requirements are concrete and already in the body; these are exact-value clarifications for implementation, not tradeoffs needing your input.

## UX / Accessibility — Leonie Voss (@leonievoss) ### Observations - This issue is designing for the hardest constraint correctly: the 60+ transcriber on a laptop/tablet doing destructive triage, and the reader on a phone who must never hit a wall of `?` cards. That framing is right. - **Undersized metadata confirmed in code.** `+page.svelte:152` life dates are `text-[11px]` and `:160` the doc-count chip is `text-[11px]`. 11px is below my 12px hard floor and is genuinely hard to read for the senior audience. Raise to ≥12px; prefer `text-sm` (14px) for life dates and the chip. - The reused `Pagination.svelte` is solid — I read it: `min-h-[44px] min-w-[44px]` controls, `focus-visible:ring-2 focus-visible:ring-brand-navy`, `aria-current="page"`, an sr-only "Seite X von Y" that stays in the AT tree at every breakpoint, and ellipsis spans are `aria-hidden`. Reuse it verbatim; rebuilding would regress all of this. - The placeholder-avatar plan (neutral `Account-MD.svg` glyph on a muted non-primary background + `unbestätigt` badge) gives the redundant cues I require: shape + text, never color alone. Good — and it doubles as the crash fix. ### Recommendations (exact values) - **Filter chips:** `<button aria-pressed={active}>`, `min-h-[44px] min-w-[44px] px-4 py-2`, `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none`. Active state must differ by more than color — add a check glyph or a filled background plus the `aria-pressed` state. Group them in a `role="group"` with an `aria-label` ("Filter"). - **Show-all / Zu-prüfen toggle:** a labeled `role="switch"` + `aria-checked`, OR paired `aria-pressed` buttons. Put the count inside the accessible name: `aria-label="Zu prüfen, 942"` — a bare "(942)" is announced as a meaningless number out of context. - **`unbestätigt` badge:** placeholder avatar (shape) + visible "unbestätigt" text + `aria-label` on the card. Use a token, not raw color, and verify contrast in dark mode — a muted badge background that passes AA in light mode can fail in dark. - **Triage action buttons (Merge · Umbenennen · Bestätigen · Löschen):** ≥44px targets, real text labels (not icon-only). "Löschen" is destructive for a 60+ user — confirmation dialog must be focus-trapped, Escape-dismissible, with focus returning to the trigger on close, and the confirm button clearly labeled ("Person löschen", not "OK"). - **States:** render `persons_empty_filtered` and `persons_review_empty` as calm centered messages matching `PersonsEmptyState`; add a grid skeleton during navigation so the reader isn't staring at a blank page on a slow phone. - **Card avatar block** is currently `bg-primary` with `text-primary-fg` initials — the provisional placeholder must be visually distinct (muted, non-primary) so "unverified" is pre-attentive, not something you have to read to discover. - **Test gate:** axe-core on `/persons` and `/persons/review` in light AND dark mode; visual regression at 320 / 768 / 1440px before merge. Chips and toggle wrap, not overflow, at 320px. ### Open Decisions _None from my side._ The accessibility requirements are concrete and already in the body; these are exact-value clarifications for implementation, not tradeoffs needing your input.
Author
Owner

DevOps / Platform — Tobias Wendt (@tobiwendt)

Observations

  • This issue adds no new Docker service, no infra component, no env var, and no new external integration. From a platform-topology view it's inert — nothing to size, nothing to open in the firewall. Good; that keeps the monthly bill flat.
  • The one operational footgun is correctly called out in the body: OpenAPI regeneration. The response shape changes from List<PersonSummaryDTO> to PersonSearchResult, plus new params and a new endpoint. If frontend/src/lib/generated/ isn't regenerated and committed, CI npm run check fails on type drift — that's a red pipeline, not a runtime issue, but it blocks the merge and wastes a CI cycle. npm run generate:api requires the backend running with --spring.profiles.active=dev.
  • E2E held to two journeys is the right call for CI time — the suite target is <8 minutes and a chip-permutation matrix in Playwright would blow that budget for no added safety.

Recommendations

  • Commit the regenerated API types in the same PR as the backend change. Make it a single atomic step in the dev's checklist so the contract and the client never diverge mid-PR.
  • Testcontainers means CI must have a Docker daemon available to the runner. The existing person integration tests already use Postgres Testcontainers, so the runner is provisioned for it — just confirming the new @DataJpaTest count-query tests don't need a new CI capability. They don't.
  • Import Pagination.svelte from the canonical path $lib/shared/primitives/Pagination.svelte — not a worktree copy. A divergent copy is a maintenance trap that silently rots when the original gets a fix.
  • No backup, retention, or restore-procedure impact: no schema change here (the column lands in #671, where the DB-diagram and any migration concerns belong). Nothing for me to add to the backup layer.

Open Decisions

None. No infrastructure decision is required for this issue. The only operational must-do — regenerate and commit the API types — is a checklist item, not a tradeoff.

## DevOps / Platform — Tobias Wendt (@tobiwendt) ### Observations - This issue adds no new Docker service, no infra component, no env var, and no new external integration. From a platform-topology view it's inert — nothing to size, nothing to open in the firewall. Good; that keeps the monthly bill flat. - The one operational footgun is correctly called out in the body: **OpenAPI regeneration.** The response shape changes from `List<PersonSummaryDTO>` to `PersonSearchResult`, plus new params and a new endpoint. If `frontend/src/lib/generated/` isn't regenerated and committed, CI `npm run check` fails on type drift — that's a red pipeline, not a runtime issue, but it blocks the merge and wastes a CI cycle. `npm run generate:api` requires the backend running with `--spring.profiles.active=dev`. - E2E held to two journeys is the right call for CI time — the suite target is <8 minutes and a chip-permutation matrix in Playwright would blow that budget for no added safety. ### Recommendations - **Commit the regenerated API types in the same PR** as the backend change. Make it a single atomic step in the dev's checklist so the contract and the client never diverge mid-PR. - **Testcontainers means CI must have a Docker daemon available to the runner.** The existing person integration tests already use Postgres Testcontainers, so the runner is provisioned for it — just confirming the new `@DataJpaTest` count-query tests don't need a new CI capability. They don't. - **Import `Pagination.svelte` from the canonical path** `$lib/shared/primitives/Pagination.svelte` — not a worktree copy. A divergent copy is a maintenance trap that silently rots when the original gets a fix. - No backup, retention, or restore-procedure impact: no schema change here (the column lands in #671, where the DB-diagram and any migration concerns belong). Nothing for me to add to the backup layer. ### Open Decisions _None._ No infrastructure decision is required for this issue. The only operational must-do — regenerate and commit the API types — is a checklist item, not a tradeoff.
Author
Owner

Requirements / Business Analysis — Elicit

Observations

  • The acceptance criteria are unusually complete: reader-default, pagination with a real server-side count, page-beyond-range, size bounds, each chip, combined-AND, the show-all toggle, the ?/null-lastName card, server-side filtering, the full triage CRUD, confirm round-trip, the permission boundary, the "hiding the link is not access control" case, empty states, and i18n coverage. INVEST-wise the story is Valuable, Testable, and Estimable. My concern is Independent/Small: it depends on #671→#669 for the exact split and bundles reader directory + transcriber triage in one issue.
  • Interim-correctness ambiguity is honestly flagged and I want to reinforce it: pre-provisional-flag, the heuristic documentCount == 0 && !familyMember wrongly pushes a confirmed register person with zero documents into triage and out of the reader default. The "163 + 942" split is only exact once #669 populates the flag. That is acceptable to ship on the heuristic — but it should be stated as a known interim limitation in the PR, not discovered later as a "bug."
  • The "Zu prüfen (N)" count has no defined source. If {count} comes from a separate provisional=true count query fired on every /persons load, that's an extra request on the reader's hot path — most of whom never triage. This is a measurable NFR question (one extra round-trip per page load), not cosmetic.

Recommendations

  • Resolve the chip-vs-default semantics in the AC before implementation. The Gherkin "activates the Institution chip → only INSTITUTION shown" reads as replace the default, but that lets a zero-document provisional institution surface in the reader view — contradicting the feature's own purpose. State explicitly: chips AND within the clean default unless review=1. One sentence in the AC removes a whole class of "is this a bug?" later. (Markus raises the same.)
  • Name the source of needsReviewCount. Tentative recommendation from the body — surface it from the loader as a cheap count rather than overloading /api/stats — is the right call: it keeps /api/stats from accreting feature-specific fields and keeps the cost on the page that needs it. Confirm and write it down.
  • Define what "unconfirmed" means pre-flag, in user terms. A reader should never see a ?-name or zero-doc card; a transcriber's triage list should contain exactly the import noise. Until #669, the heuristic is the contract — document it so QA's fixtures and the PO's expectations match.
  • Keep the out-of-scope list intact: no bulk-merge/auto-dedup, no Briefwechsel wiring, no life-date changes. These are correctly parked.

Open Decisions

  • Chip semantics: AND-within-default vs replace-default. Replace lets provisional/zero-doc rows leak into the reader view (re-introduces the noise); AND-within-default preserves the clean invariant but means a reader cannot, e.g., browse ALL institutions including empty ones without flipping review=1. Decide based on what a reader should be allowed to stumble into.
  • needsReviewCount source: loader count call vs /api/stats field. Loader call = one extra request per /persons load, no cross-feature coupling. /api/stats field = no extra request but /api/stats grows a triage-specific field used by one page. Tradeoff is request-count vs endpoint cohesion.
  • Scope/sequencing: ship on heuristic now, or wait for #669? Shipping now delivers pagination + filtering + the crash fix immediately but means the reader/triage split is approximate until #669 lands. Waiting gives an exact split but blocks user-visible value behind the full chain. (The body leans ship-now; confirm that's the intent.)
## Requirements / Business Analysis — Elicit ### Observations - The acceptance criteria are unusually complete: reader-default, pagination with a real server-side count, page-beyond-range, size bounds, each chip, combined-AND, the show-all toggle, the `?`/null-lastName card, server-side filtering, the full triage CRUD, confirm round-trip, the permission boundary, the "hiding the link is not access control" case, empty states, and i18n coverage. INVEST-wise the story is Valuable, Testable, and Estimable. My concern is **Independent/Small**: it depends on #671→#669 for the *exact* split and bundles reader directory + transcriber triage in one issue. - **Interim-correctness ambiguity is honestly flagged** and I want to reinforce it: pre-`provisional`-flag, the heuristic `documentCount == 0 && !familyMember` wrongly pushes a *confirmed register person with zero documents* into triage and out of the reader default. The "163 + 942" split is only exact once #669 populates the flag. That is acceptable to ship on the heuristic — but it should be stated as a known interim limitation in the PR, not discovered later as a "bug." - The "Zu prüfen (N)" count has no defined source. If `{count}` comes from a separate `provisional=true` count query fired on every `/persons` load, that's an extra request on the reader's hot path — most of whom never triage. This is a measurable NFR question (one extra round-trip per page load), not cosmetic. ### Recommendations - **Resolve the chip-vs-default semantics in the AC before implementation.** The Gherkin "activates the Institution chip → only INSTITUTION shown" reads as *replace the default*, but that lets a zero-document provisional institution surface in the reader view — contradicting the feature's own purpose. State explicitly: chips AND within the clean default unless `review=1`. One sentence in the AC removes a whole class of "is this a bug?" later. (Markus raises the same.) - **Name the source of `needsReviewCount`.** Tentative recommendation from the body — surface it from the loader as a cheap count rather than overloading `/api/stats` — is the right call: it keeps `/api/stats` from accreting feature-specific fields and keeps the cost on the page that needs it. Confirm and write it down. - **Define what "unconfirmed" means pre-flag, in user terms.** A reader should never see a `?`-name or zero-doc card; a transcriber's triage list should contain exactly the import noise. Until #669, the heuristic is the contract — document it so QA's fixtures and the PO's expectations match. - Keep the out-of-scope list intact: no bulk-merge/auto-dedup, no Briefwechsel wiring, no life-date changes. These are correctly parked. ### Open Decisions - **Chip semantics: AND-within-default vs replace-default.** Replace lets provisional/zero-doc rows leak into the reader view (re-introduces the noise); AND-within-default preserves the clean invariant but means a reader cannot, e.g., browse ALL institutions including empty ones without flipping `review=1`. Decide based on what a reader should be allowed to stumble into. - **`needsReviewCount` source: loader count call vs `/api/stats` field.** Loader call = one extra request per `/persons` load, no cross-feature coupling. `/api/stats` field = no extra request but `/api/stats` grows a triage-specific field used by one page. Tradeoff is request-count vs endpoint cohesion. - **Scope/sequencing: ship on heuristic now, or wait for #669?** Shipping now delivers pagination + filtering + the crash fix immediately but means the reader/triage split is approximate until #669 lands. Waiting gives an exact split but blocks user-visible value behind the full chain. (The body leans ship-now; confirm that's the intent.)
Author
Owner

Decision Queue — Action Required

5 decisions need your input before implementation starts. Everything else in the body is resolved; personas made concrete recommendations elsewhere.

Behaviour / UX

  • Do filter chips AND with the reader default, or replace it? This is the highest-impact open item. If a type=INSTITUTION chip replaces the familyMember || documentCount > 0 default, a zero-document provisional institution leaks back into the reader view — re-introducing the exact noise this feature removes. ANDing chips within the clean default (unless review=1 is set) preserves the invariant, at the cost that a reader can't browse ALL institutions (incl. empty ones) without flipping the toggle. The current Gherkin reads as "replace" but the feature intent implies "AND". Pick one and pin it in the AC. (Raised by: Markus, Elicit)

Architecture

  • Paged response field naming — confirm items / totalElements / pageNumber / pageSize / totalPages. The body resolves toward matching DocumentSearchResult (verified to use exactly this shape). Strongly endorsed — one paged shape across the app. Only revisit if you ever intend to expose Spring Page (content/number) shapes; the alternative leaves two divergent shapes forever. Likely a rubber-stamp. (Raised by: Markus)
  • needsReviewCount source: loader count call vs a new /api/stats field. Loader call = one extra request per /persons load, no cross-feature coupling. /api/stats field = no extra request, but /api/stats grows a triage-specific field used by one page. Tradeoff is request-count vs endpoint cohesion. Tentative lean: loader count. (Raised by: Elicit)

Security / Trust model

  • Are provisional / zero-document rows reachable via the read API by READ_ALL users at all? Tentative (body + AC): leave readable — consistent with "all family members are trusted; readers just can't edit," simplest, no second check on a GET. Alternative: gate review=1/provisional=true behind WRITE_ALL, diverging from "readers see everything, just can't change it." This is a trust-model choice, not a vulnerability. Confirm the leave-readable AC is intended. (Raised by: Nora)

Scope / sequencing

  • Ship on the heuristic now, or wait for #669 to populate provisional? Ship-now delivers pagination + filtering + the null-avatar crash fix immediately, but the reader/triage split is approximate until #669 (a confirmed register person with zero documents is wrongly pushed into triage in the interim). Waiting gives an exact split but blocks all user-visible value behind the full #671→#669 chain. The body leans ship-now with the heuristic documented as a known interim limitation — confirm that's the intent. (Raised by: Elicit)
## Decision Queue — Action Required _5 decisions need your input before implementation starts. Everything else in the body is resolved; personas made concrete recommendations elsewhere._ ### Behaviour / UX - **Do filter chips AND with the reader default, or replace it?** This is the highest-impact open item. If a `type=INSTITUTION` chip *replaces* the `familyMember || documentCount > 0` default, a zero-document provisional institution leaks back into the reader view — re-introducing the exact noise this feature removes. ANDing chips within the clean default (unless `review=1` is set) preserves the invariant, at the cost that a reader can't browse ALL institutions (incl. empty ones) without flipping the toggle. The current Gherkin reads as "replace" but the feature intent implies "AND". Pick one and pin it in the AC. _(Raised by: Markus, Elicit)_ ### Architecture - **Paged response field naming — confirm `items` / `totalElements` / `pageNumber` / `pageSize` / `totalPages`.** The body resolves toward matching `DocumentSearchResult` (verified to use exactly this shape). Strongly endorsed — one paged shape across the app. Only revisit if you ever intend to expose Spring `Page` (`content`/`number`) shapes; the alternative leaves two divergent shapes forever. Likely a rubber-stamp. _(Raised by: Markus)_ - **`needsReviewCount` source: loader count call vs a new `/api/stats` field.** Loader call = one extra request per `/persons` load, no cross-feature coupling. `/api/stats` field = no extra request, but `/api/stats` grows a triage-specific field used by one page. Tradeoff is request-count vs endpoint cohesion. Tentative lean: loader count. _(Raised by: Elicit)_ ### Security / Trust model - **Are provisional / zero-document rows reachable via the read API by `READ_ALL` users at all?** Tentative (body + AC): leave readable — consistent with "all family members are trusted; readers just can't edit," simplest, no second check on a GET. Alternative: gate `review=1`/`provisional=true` behind `WRITE_ALL`, diverging from "readers see everything, just can't change it." This is a trust-model choice, not a vulnerability. Confirm the leave-readable AC is intended. _(Raised by: Nora)_ ### Scope / sequencing - **Ship on the heuristic now, or wait for #669 to populate `provisional`?** Ship-now delivers pagination + filtering + the null-avatar crash fix immediately, but the reader/triage split is approximate until #669 (a confirmed register person with zero documents is wrongly pushed into triage in the interim). Waiting gives an exact split but blocks all user-visible value behind the full #671→#669 chain. The body leans ship-now with the heuristic documented as a known interim limitation — confirm that's the intent. _(Raised by: Elicit)_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#667