feat: Persons section redesign — Concept A (Enriched Directory) #157

Closed
opened 2026-03-29 16:48:58 +02:00 by marcel · 3 comments
Owner

Overview

Redesign of the Persons section (/persons, /persons/[id], /persons/new) based on a UX audit across mobile, tablet, and desktop. Concept A ("Enriched Directory") takes an evolutionary approach — enrich what exists without a structural overhaul.

📄 Full wireframe spec → docs/specs/persons-concept-a-enriched-directory.html


UX Audit — Problems being fixed

Severity Issue
🔴 Critical Merge person panel always visible and prominent — destructive action should be hidden
🔴 Critical "Full Name" label in view mode duplicates the heading (shown twice)
🔴 Critical Edit button is tiny touch target on mobile — too small to tap reliably
🟠 High New person form only has 3 fields — birth year, death year, notes missing at creation
🟠 High Person cards show only name + initials — no alias, life dates, or document count
🟠 High Edit form has no sticky save bar — Save button scrolls off screen with long Notes
🟠 High No clear visual separation between person metadata and document activity
🟡 Medium Co-correspondents section has no context — click target is unclear
🟡 Medium Document status badges show raw enum values (PLACEHOLDER, UPLOADED)
🟡 Medium No stats bar on list page
🔵 Low No alphabet/quick-nav for large person sets

Proposed changes

Routes

Route Change
/persons Richer cards + stats bar
/persons/new Add all fields (birth year, death year, notes)
/persons/[id] New 2-column layout; remove inline edit mode
/persons/[id]/edit New route — dedicated edit page with sticky save bar + danger zone

Person list (/persons)

  • Stats bar: "4 persons · 12 documents"
  • Cards enriched: alias in italic, life date range (* 1882 – † 1944), document count chip
  • Empty state with illustration and CTA
  • Search still debounced, shows result count

Person detail (/persons/[id]) — 2-column layout on desktop (≥1024px)

┌──────────────────────────┬──────────────────────────────────────┐
│  Person Card  (35%)      │  Activity Area  (65%)                │
│  - Avatar circle (96px)  │  - Frequent Correspondents (chips)   │
│  - Full name (serif h1)  │  - Sent Documents list               │
│  - Alias italic          │  - Received Documents list           │
│  - Life dates            │                                      │
│  - Notes                 │                                      │
│  [Edit person] button    │                                      │
└──────────────────────────┴──────────────────────────────────────┘
  • Tablet/mobile: stacked single column
  • "Full Name" label removed (was duplicating the h1)
  • Co-correspondent chips get a chat icon + tooltip clarifying they open conversations
  • Document status labels mapped to human-readable strings

Edit person (/persons/[id]/edit) — new dedicated route

  • All fields: first name, last name, alias, birth year, death year, notes
  • Sticky full-bleed save bar: Discard changes | Save changes
  • Danger Zone at the bottom — collapsed accordion by default
    • Expanded shows: merge typeahead + "Merge [name] →" red button + irreversibility warning
    • PersonMergePanel.svelte moves here from the detail page

New person (/persons/new)

  • Adds missing fields: birth year, death year, notes
  • Same card + save bar layout as edit page

Breakpoints

Viewport List Detail Edit
<640px Mobile 1-col list, compact cards Stacked single column Full-page form, sticky bar
640–1023px Tablet 2-col grid Stacked single column Full-page form
≥1024px Desktop 4-col grid with stats bar 2-column sidebar + activity Full-page form, max-w-4xl

Files to create / modify

File Action Change
src/routes/persons/+page.svelte Modify Add stats bar, enrich card template
src/routes/persons/+page.server.ts Modify Optionally add documentCount to PersonDTO
src/routes/persons/new/+page.svelte Modify Add birth year, death year, notes fields
src/routes/persons/new/+page.server.ts Modify Handle new fields in create action
src/routes/persons/[id]/+page.svelte Modify Replace vertical stack with 2-column grid; remove edit toggle
src/routes/persons/[id]/PersonCard.svelte Modify Remove edit mode, remove "Full Name" duplication, add Edit button linking to /edit
src/routes/persons/[id]/PersonMergePanel.svelte Move Relocate to edit route
src/routes/persons/[id]/edit/+page.svelte Create New edit form with sticky save bar
src/routes/persons/[id]/edit/+page.server.ts Create Move update + merge actions here
src/lib/i18n/messages/*.json Modify Add human-readable status labels

Acceptance criteria

  • Person cards on list show alias (if set), life date range (if set), document count chip
  • Stats bar shows total person count and total document count
  • Empty state shown when no persons exist
  • /persons/new has all fields: first name, last name, alias, birth year, death year, notes
  • /persons/[id] shows 2-column layout on desktop (≥1024px), stacked on smaller
  • "Full Name" label removed from detail view (heading is sufficient)
  • Edit button on detail links to /persons/[id]/edit (no in-place toggle)
  • /persons/[id]/edit has sticky save bar with Discard and Save changes
  • Danger Zone accordion collapsed by default on edit page
  • Merge panel works from within the Danger Zone on the edit page
  • Co-correspondent chips have clarifying tooltip/icon for the conversation link
  • Document status enums mapped to human-readable strings in the UI
  • All breakpoints: mobile (375px), tablet (768px), desktop (1440px) look correct

Spec authored by Leonie Voss (UI/UX persona) · Concept A · v1.0 · 2026-03
Wireframe spec: docs/specs/persons-concept-a-enriched-directory.html

## Overview Redesign of the Persons section (`/persons`, `/persons/[id]`, `/persons/new`) based on a UX audit across mobile, tablet, and desktop. Concept A ("Enriched Directory") takes an evolutionary approach — enrich what exists without a structural overhaul. 📄 **[Full wireframe spec → docs/specs/persons-concept-a-enriched-directory.html](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/persons-concept-a-enriched-directory.html)** --- ## UX Audit — Problems being fixed | Severity | Issue | |---|---| | 🔴 Critical | Merge person panel always visible and prominent — destructive action should be hidden | | 🔴 Critical | "Full Name" label in view mode duplicates the heading (shown twice) | | 🔴 Critical | Edit button is tiny touch target on mobile — too small to tap reliably | | 🟠 High | New person form only has 3 fields — birth year, death year, notes missing at creation | | 🟠 High | Person cards show only name + initials — no alias, life dates, or document count | | 🟠 High | Edit form has no sticky save bar — Save button scrolls off screen with long Notes | | 🟠 High | No clear visual separation between person metadata and document activity | | 🟡 Medium | Co-correspondents section has no context — click target is unclear | | 🟡 Medium | Document status badges show raw enum values (`PLACEHOLDER`, `UPLOADED`) | | 🟡 Medium | No stats bar on list page | | 🔵 Low | No alphabet/quick-nav for large person sets | --- ## Proposed changes ### Routes | Route | Change | |---|---| | `/persons` | Richer cards + stats bar | | `/persons/new` | Add all fields (birth year, death year, notes) | | `/persons/[id]` | New 2-column layout; remove inline edit mode | | `/persons/[id]/edit` | **New route** — dedicated edit page with sticky save bar + danger zone | ### Person list (`/persons`) - Stats bar: "4 persons · 12 documents" - Cards enriched: alias in italic, life date range (`* 1882 – † 1944`), document count chip - Empty state with illustration and CTA - Search still debounced, shows result count ### Person detail (`/persons/[id]`) — 2-column layout on desktop (≥1024px) ``` ┌──────────────────────────┬──────────────────────────────────────┐ │ Person Card (35%) │ Activity Area (65%) │ │ - Avatar circle (96px) │ - Frequent Correspondents (chips) │ │ - Full name (serif h1) │ - Sent Documents list │ │ - Alias italic │ - Received Documents list │ │ - Life dates │ │ │ - Notes │ │ │ [Edit person] button │ │ └──────────────────────────┴──────────────────────────────────────┘ ``` - Tablet/mobile: stacked single column - "Full Name" label removed (was duplicating the h1) - Co-correspondent chips get a chat icon + tooltip clarifying they open conversations - Document status labels mapped to human-readable strings ### Edit person (`/persons/[id]/edit`) — new dedicated route - All fields: first name, last name, alias, birth year, death year, notes - Sticky full-bleed save bar: `Discard changes` | `Save changes` - **Danger Zone** at the bottom — collapsed accordion by default - Expanded shows: merge typeahead + "Merge [name] →" red button + irreversibility warning - `PersonMergePanel.svelte` moves here from the detail page ### New person (`/persons/new`) - Adds missing fields: birth year, death year, notes - Same card + save bar layout as edit page --- ## Breakpoints | Viewport | List | Detail | Edit | |---|---|---|---| | `<640px` Mobile | 1-col list, compact cards | Stacked single column | Full-page form, sticky bar | | `640–1023px` Tablet | 2-col grid | Stacked single column | Full-page form | | `≥1024px` Desktop | 4-col grid with stats bar | 2-column sidebar + activity | Full-page form, max-w-4xl | --- ## Files to create / modify | File | Action | Change | |---|---|---| | `src/routes/persons/+page.svelte` | Modify | Add stats bar, enrich card template | | `src/routes/persons/+page.server.ts` | Modify | Optionally add `documentCount` to PersonDTO | | `src/routes/persons/new/+page.svelte` | Modify | Add birth year, death year, notes fields | | `src/routes/persons/new/+page.server.ts` | Modify | Handle new fields in create action | | `src/routes/persons/[id]/+page.svelte` | Modify | Replace vertical stack with 2-column grid; remove edit toggle | | `src/routes/persons/[id]/PersonCard.svelte` | Modify | Remove edit mode, remove "Full Name" duplication, add Edit button linking to /edit | | `src/routes/persons/[id]/PersonMergePanel.svelte` | Move | Relocate to edit route | | `src/routes/persons/[id]/edit/+page.svelte` | **Create** | New edit form with sticky save bar | | `src/routes/persons/[id]/edit/+page.server.ts` | **Create** | Move update + merge actions here | | `src/lib/i18n/messages/*.json` | Modify | Add human-readable status labels | --- ## Acceptance criteria - [ ] Person cards on list show alias (if set), life date range (if set), document count chip - [ ] Stats bar shows total person count and total document count - [ ] Empty state shown when no persons exist - [ ] `/persons/new` has all fields: first name, last name, alias, birth year, death year, notes - [ ] `/persons/[id]` shows 2-column layout on desktop (≥1024px), stacked on smaller - [ ] "Full Name" label removed from detail view (heading is sufficient) - [ ] Edit button on detail links to `/persons/[id]/edit` (no in-place toggle) - [ ] `/persons/[id]/edit` has sticky save bar with Discard and Save changes - [ ] Danger Zone accordion collapsed by default on edit page - [ ] Merge panel works from within the Danger Zone on the edit page - [ ] Co-correspondent chips have clarifying tooltip/icon for the conversation link - [ ] Document status enums mapped to human-readable strings in the UI - [ ] All breakpoints: mobile (375px), tablet (768px), desktop (1440px) look correct --- *Spec authored by Leonie Voss (UI/UX persona) · Concept A · v1.0 · 2026-03* *Wireframe spec: [docs/specs/persons-concept-a-enriched-directory.html](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/persons-concept-a-enriched-directory.html)*
marcel added the featureui labels 2026-03-29 16:49:08 +02:00
Author
Owner

Architectural Review — @mkeller

The UX spec is sound and the proposed file changes are appropriate. The route split ([id] → view-only, [id]/edit → form + danger zone) is the right call — it removes the awkward inline-edit toggle and gives the merge panel a natural home. No structural concerns with the SvelteKit side.

There are three backend gaps that need resolving before implementation starts. None are large, but two will require deliberate query design and one is an N+1 trap if done naively.


Gap 1: POST /api/persons cannot accept the new fields

Current state: PersonController.createPerson() takes Map<String, String> and passes three positional strings to PersonService.createPerson(String, String, String). birthYear and deathYear are integers — they cannot be added to this endpoint without parsing them from strings in the controller, which is controller logic leaking into the wrong layer.

What the spec needs: The /persons/new form will submit birthYear, deathYear, and notes on creation.

Recommendation:

  1. Introduce a PersonCreateDTO (or reuse PersonUpdateDTO — the fields are identical) as the request body for POST /api/persons.
  2. Update PersonService.createPerson() to accept the full DTO, same pattern as updatePerson().
  3. Run npm run generate:api after — the frontend create action currently sends firstName/lastName/alias as a raw JSON body; it needs to match the new DTO.

This is migration-free — birthYear/deathYear/notes already exist on the entity.


Gap 2: Document count on person cards — N+1 risk

What the spec asks for: Each person card on /persons shows a document count chip. The spec notes this as "Optionally add documentCount to PersonDTO".

Current state: GET /api/persons returns bare Person entities. If documentCount is added naively (a @Formula subquery per row, or a loop calling documentService per person), you fire one extra SQL query per person row.

Recommendation: Add a single native query that returns the count in the same round-trip:

SELECT p.*,
    (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
    + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)
    AS document_count
FROM persons p
ORDER BY p.last_name ASC, p.first_name ASC

Surface this via a PersonSummaryDTO (a record with all Person fields plus documentCount) returned only by GET /api/persons. The GET /api/persons/{id} detail endpoint keeps returning the raw Person entity — no document count needed there.

Trade-off: you add a DTO and a new repository method. Cost is low. N+1 on a list page is not acceptable.


Gap 3: Stats bar totals require a new API surface

What the spec asks for: "4 persons · 12 documents" on the list page.

Current state: No endpoint returns aggregate counts. The frontend could derive persons.length client-side but totalDocumentCount is not available anywhere.

Options:

Option Cost Notes
Piggyback on the list response — wrap in { persons: [...], totalDocuments: N } Low Breaking change to the response shape; forces generate:api
Add GET /api/stats returning { totalPersons, totalDocuments } Low Clean, reusable, non-breaking
Derive totalPersons from persons.length client-side, add only totalDocuments to the list response Low Pragmatic but inconsistent

Recommendation: Add GET /api/stats — a single endpoint, two COUNT(*) queries. Keep the persons list response shape unchanged. The frontend load function calls both in Promise.all(). Useful beyond this feature (dashboard widgets, admin page).


What needs no backend changes

  • /persons/[id]/edit route — move update and merge actions from [id]/+page.server.ts to [id]/edit/+page.server.ts. Backend endpoints unchanged. The redirect(303, '/persons/${targetPersonId}') after merge still works.
  • Status label mapping — purely frontend.
  • Responsive layout, tooltips, empty state — purely frontend.

One existing issue worth fixing while you're here

PersonService.getById() and updatePerson() throw ResponseStatusException directly. The rest of the codebase uses DomainException. Not a blocker, but if you touch PersonService to extend createPerson(), standardise the error throwing in the same commit.


Summary of backend work required

Task Files Breaking?
PersonCreateDTO + update POST /api/persons PersonController, PersonService, PersonCreateDTO (new) Yes — regenerate API types
PersonSummaryDTO with documentCount + native query PersonRepository, PersonService, PersonController, PersonSummaryDTO (new) Yes — regenerate API types
GET /api/stats endpoint new StatsController or add to existing No

Do the backend changes first, regenerate the API types, then implement the frontend. Do not start the SvelteKit work against stale types.

## Architectural Review — @mkeller The UX spec is sound and the proposed file changes are appropriate. The route split (`[id]` → view-only, `[id]/edit` → form + danger zone) is the right call — it removes the awkward inline-edit toggle and gives the merge panel a natural home. No structural concerns with the SvelteKit side. There are **three backend gaps** that need resolving before implementation starts. None are large, but two will require deliberate query design and one is an N+1 trap if done naively. --- ### Gap 1: `POST /api/persons` cannot accept the new fields **Current state:** `PersonController.createPerson()` takes `Map<String, String>` and passes three positional strings to `PersonService.createPerson(String, String, String)`. `birthYear` and `deathYear` are integers — they cannot be added to this endpoint without parsing them from strings in the controller, which is controller logic leaking into the wrong layer. **What the spec needs:** The `/persons/new` form will submit `birthYear`, `deathYear`, and `notes` on creation. **Recommendation:** 1. Introduce a `PersonCreateDTO` (or reuse `PersonUpdateDTO` — the fields are identical) as the request body for `POST /api/persons`. 2. Update `PersonService.createPerson()` to accept the full DTO, same pattern as `updatePerson()`. 3. Run `npm run generate:api` after — the frontend create action currently sends `firstName`/`lastName`/`alias` as a raw JSON body; it needs to match the new DTO. This is migration-free — `birthYear`/`deathYear`/`notes` already exist on the entity. --- ### Gap 2: Document count on person cards — N+1 risk **What the spec asks for:** Each person card on `/persons` shows a document count chip. The spec notes this as "Optionally add `documentCount` to PersonDTO". **Current state:** `GET /api/persons` returns bare `Person` entities. If `documentCount` is added naively (a `@Formula` subquery per row, or a loop calling `documentService` per person), you fire one extra SQL query per person row. **Recommendation:** Add a single native query that returns the count in the same round-trip: ```sql SELECT p.*, (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS document_count FROM persons p ORDER BY p.last_name ASC, p.first_name ASC ``` Surface this via a `PersonSummaryDTO` (a record with all `Person` fields plus `documentCount`) returned only by `GET /api/persons`. The `GET /api/persons/{id}` detail endpoint keeps returning the raw `Person` entity — no document count needed there. Trade-off: you add a DTO and a new repository method. Cost is low. N+1 on a list page is not acceptable. --- ### Gap 3: Stats bar totals require a new API surface **What the spec asks for:** `"4 persons · 12 documents"` on the list page. **Current state:** No endpoint returns aggregate counts. The frontend could derive `persons.length` client-side but `totalDocumentCount` is not available anywhere. **Options:** | Option | Cost | Notes | |---|---|---| | Piggyback on the list response — wrap in `{ persons: [...], totalDocuments: N }` | Low | Breaking change to the response shape; forces `generate:api` | | Add `GET /api/stats` returning `{ totalPersons, totalDocuments }` | Low | Clean, reusable, non-breaking | | Derive `totalPersons` from `persons.length` client-side, add only `totalDocuments` to the list response | Low | Pragmatic but inconsistent | **Recommendation:** Add `GET /api/stats` — a single endpoint, two `COUNT(*)` queries. Keep the persons list response shape unchanged. The frontend load function calls both in `Promise.all()`. Useful beyond this feature (dashboard widgets, admin page). --- ### What needs no backend changes - **`/persons/[id]/edit` route** — move `update` and `merge` actions from `[id]/+page.server.ts` to `[id]/edit/+page.server.ts`. Backend endpoints unchanged. The `redirect(303, '/persons/${targetPersonId}')` after merge still works. - **Status label mapping** — purely frontend. - **Responsive layout, tooltips, empty state** — purely frontend. --- ### One existing issue worth fixing while you're here `PersonService.getById()` and `updatePerson()` throw `ResponseStatusException` directly. The rest of the codebase uses `DomainException`. Not a blocker, but if you touch `PersonService` to extend `createPerson()`, standardise the error throwing in the same commit. --- ### Summary of backend work required | Task | Files | Breaking? | |---|---|---| | `PersonCreateDTO` + update `POST /api/persons` | `PersonController`, `PersonService`, `PersonCreateDTO` (new) | Yes — regenerate API types | | `PersonSummaryDTO` with `documentCount` + native query | `PersonRepository`, `PersonService`, `PersonController`, `PersonSummaryDTO` (new) | Yes — regenerate API types | | `GET /api/stats` endpoint | new `StatsController` or add to existing | No | **Do the backend changes first**, regenerate the API types, then implement the frontend. Do not start the SvelteKit work against stale types.
Author
Owner

Test Scope — Concept A Persons Redesign

Sara Holt (@saraholt) · QA


Layer 1 — Static Analysis

svelte-check + TypeScript strict + ESLint run on every commit as usual. Any new $lib import path that isn't typed is a CI blocker before anything else runs.


Layer 2 — Unit Tests

src/lib/utils/personStatus.test.ts (new)

Test Covers
maps PLACEHOLDER to human-readable label Status enum → display string
maps UPLOADED to human-readable label Status enum → display string
maps TRANSCRIBED, REVIEWED, ARCHIVED All remaining enum values
returns fallback for unknown status Unknown enum value doesn't crash
formats life date range with both dates * 1882 – † 1944
formats life date range with birth year only * 1882 — no death year
formats life date range with neither Empty string, not * –

src/lib/utils/personStats.test.ts (if stats bar logic is extracted)

Test Covers
formats stats bar with plural persons "4 persons · 12 documents"
formats stats bar with singular "1 person · 1 document"
handles zero counts Empty state path

Layer 3 — Integration Tests

src/routes/persons/[id]/edit/+page.server.test.ts (new — critical)

Test Covers
load returns person data for valid id Happy path — all fields present
load throws 404 for unknown person id Error path
load throws 403 for unauthenticated user Security boundary
update action saves all fields including birth year, death year, notes New fields persist
update action returns validation error for invalid birth year e.g. "abc" in year field
discard action redirects to /persons/[id] without saving Discard behavior
merge action delegates to PersonService and redirects Merge works from new location
merge action returns 404 when target person does not exist Error path

src/routes/persons/new/+page.server.test.ts (modify existing)

Test Covers
create action accepts birth year, death year, notes New fields added to form
create action succeeds with only required fields (firstName, lastName) New fields are optional

src/routes/persons/+page.server.test.ts (modify existing, only if documentCount added to PersonDTO)

Test Covers
load returns documentCount per person Field present in response
load returns total document count for stats bar New aggregation

Layer 4 — E2E Tests (Playwright)

checkA11y() runs on every page entry and every navigation target.

e2e/persons/persons-list.spec.ts (modify)

Test Covers
stats bar shows person count and document count New stats bar
person card shows alias when set Enriched card
person card shows life date range when set Enriched card
person card shows document count chip Enriched card
empty state shown when no persons exist Empty state path
search filters persons and shows result count Regression
accessibility: /persons passes axe at all breakpoints a11y gate

e2e/persons/person-detail.spec.ts (modify)

Test Covers
2-column layout renders on desktop (1440px) Grid breakpoint
stacked single-column on tablet (768px) Tablet breakpoint
stacked single-column on mobile (375px) Mobile breakpoint
Full Name label is not visible in detail view Regression: duplication removed
Edit person button links to /persons/[id]/edit No in-place toggle
co-correspondent chips show tooltip or icon UX clarification present
document status shows human-readable string not raw enum e.g. "Uploaded" not "UPLOADED"
accessibility: /persons/[id] passes axe at all breakpoints a11y gate

e2e/persons/person-edit.spec.ts (new)

Test Covers
edit page loads with all fields pre-filled Data integrity from load
sticky save bar remains visible while scrolling long notes Sticky behavior
Save changes persists all fields and redirects to detail Happy path
Discard changes returns to detail without saving Discard path
Danger Zone accordion is collapsed on page load Default state: hidden
Danger Zone expands on click and shows merge panel Accordion interaction
merge flow completes from Danger Zone Merge still functional at new location
accessibility: /persons/[id]/edit passes axe a11y gate

e2e/persons/person-new.spec.ts (modify)

Test Covers
new person form has birth year, death year, notes fields Fields not missing at creation
create with all fields succeeds and redirects to detail Happy path with new fields
create with only required fields succeeds Optional fields are optional

Visual regression — run at 375px, 768px, 1440px in light and dark mode for all four routes (/persons, /persons/[id], /persons/[id]/edit, /persons/new). That's 24 snapshots. All diffs block merge pending Leonie's review.


Testability Concerns

1. documentCount on PersonDTO — needs a @DataJpaTest slice if computed via JOIN/subquery. A mock would let a broken query ship silently.

2. Danger Zone accordion — assert behavior, not class names. Test that the merge panel is hidden/visible, not that a CSS class is present. The test must survive implementation changes.

3. Merge moved to edit route — explicit regression required. The E2E merge test must use /persons/[id]/edit. Verify no stale action handler remains on the old /persons/[id] path.

4. Status label mapping — must be extracted and unit-tested. If the enum → display string mapping lives inline in the template, it's untestable. Extract it to a utility function. Any future DocumentStatus addition will otherwise regress silently.


Estimated CI impact

Layer Added time
Unit tests +3–5 seconds
Integration (load functions) +15–25 seconds
E2E (new + modified specs) +90–120 seconds
Visual regression (24 snapshots) +60–90 seconds

Total stays well within the 8-minute E2E budget.


Blockers before first test can be written

  1. Is documentCount added to PersonDTO or computed in the frontend load function? Determines which layer owns that test.
  2. Where does the status label mapping live — inline template expression or extracted utility? If inline, that's a testability flag: extract it first.
  3. Docker Compose smoke test passing before E2E work starts.
## Test Scope — Concept A Persons Redesign *Sara Holt (@saraholt) · QA* --- ### Layer 1 — Static Analysis `svelte-check` + TypeScript strict + ESLint run on every commit as usual. Any new `$lib` import path that isn't typed is a CI blocker before anything else runs. --- ### Layer 2 — Unit Tests **`src/lib/utils/personStatus.test.ts`** (new) | Test | Covers | |---|---| | `maps PLACEHOLDER to human-readable label` | Status enum → display string | | `maps UPLOADED to human-readable label` | Status enum → display string | | `maps TRANSCRIBED, REVIEWED, ARCHIVED` | All remaining enum values | | `returns fallback for unknown status` | Unknown enum value doesn't crash | | `formats life date range with both dates` | `* 1882 – † 1944` | | `formats life date range with birth year only` | `* 1882` — no death year | | `formats life date range with neither` | Empty string, not `* –` | **`src/lib/utils/personStats.test.ts`** (if stats bar logic is extracted) | Test | Covers | |---|---| | `formats stats bar with plural persons` | "4 persons · 12 documents" | | `formats stats bar with singular` | "1 person · 1 document" | | `handles zero counts` | Empty state path | --- ### Layer 3 — Integration Tests **`src/routes/persons/[id]/edit/+page.server.test.ts`** (new — critical) | Test | Covers | |---|---| | `load returns person data for valid id` | Happy path — all fields present | | `load throws 404 for unknown person id` | Error path | | `load throws 403 for unauthenticated user` | Security boundary | | `update action saves all fields including birth year, death year, notes` | New fields persist | | `update action returns validation error for invalid birth year` | e.g. "abc" in year field | | `discard action redirects to /persons/[id] without saving` | Discard behavior | | `merge action delegates to PersonService and redirects` | Merge works from new location | | `merge action returns 404 when target person does not exist` | Error path | **`src/routes/persons/new/+page.server.test.ts`** (modify existing) | Test | Covers | |---|---| | `create action accepts birth year, death year, notes` | New fields added to form | | `create action succeeds with only required fields (firstName, lastName)` | New fields are optional | **`src/routes/persons/+page.server.test.ts`** (modify existing, only if `documentCount` added to PersonDTO) | Test | Covers | |---|---| | `load returns documentCount per person` | Field present in response | | `load returns total document count for stats bar` | New aggregation | --- ### Layer 4 — E2E Tests (Playwright) `checkA11y()` runs on every page entry and every navigation target. **`e2e/persons/persons-list.spec.ts`** (modify) | Test | Covers | |---|---| | `stats bar shows person count and document count` | New stats bar | | `person card shows alias when set` | Enriched card | | `person card shows life date range when set` | Enriched card | | `person card shows document count chip` | Enriched card | | `empty state shown when no persons exist` | Empty state path | | `search filters persons and shows result count` | Regression | | `accessibility: /persons passes axe at all breakpoints` | a11y gate | **`e2e/persons/person-detail.spec.ts`** (modify) | Test | Covers | |---|---| | `2-column layout renders on desktop (1440px)` | Grid breakpoint | | `stacked single-column on tablet (768px)` | Tablet breakpoint | | `stacked single-column on mobile (375px)` | Mobile breakpoint | | `Full Name label is not visible in detail view` | Regression: duplication removed | | `Edit person button links to /persons/[id]/edit` | No in-place toggle | | `co-correspondent chips show tooltip or icon` | UX clarification present | | `document status shows human-readable string not raw enum` | e.g. "Uploaded" not "UPLOADED" | | `accessibility: /persons/[id] passes axe at all breakpoints` | a11y gate | **`e2e/persons/person-edit.spec.ts`** (new) | Test | Covers | |---|---| | `edit page loads with all fields pre-filled` | Data integrity from load | | `sticky save bar remains visible while scrolling long notes` | Sticky behavior | | `Save changes persists all fields and redirects to detail` | Happy path | | `Discard changes returns to detail without saving` | Discard path | | `Danger Zone accordion is collapsed on page load` | Default state: hidden | | `Danger Zone expands on click and shows merge panel` | Accordion interaction | | `merge flow completes from Danger Zone` | Merge still functional at new location | | `accessibility: /persons/[id]/edit passes axe` | a11y gate | **`e2e/persons/person-new.spec.ts`** (modify) | Test | Covers | |---|---| | `new person form has birth year, death year, notes fields` | Fields not missing at creation | | `create with all fields succeeds and redirects to detail` | Happy path with new fields | | `create with only required fields succeeds` | Optional fields are optional | **Visual regression** — run at **375px, 768px, 1440px** in **light and dark mode** for all four routes (`/persons`, `/persons/[id]`, `/persons/[id]/edit`, `/persons/new`). That's **24 snapshots**. All diffs block merge pending Leonie's review. --- ### Testability Concerns **1. `documentCount` on PersonDTO — needs a `@DataJpaTest` slice if computed via JOIN/subquery.** A mock would let a broken query ship silently. **2. Danger Zone accordion — assert behavior, not class names.** Test that the merge panel is hidden/visible, not that a CSS class is present. The test must survive implementation changes. **3. Merge moved to edit route — explicit regression required.** The E2E merge test must use `/persons/[id]/edit`. Verify no stale action handler remains on the old `/persons/[id]` path. **4. Status label mapping — must be extracted and unit-tested.** If the enum → display string mapping lives inline in the template, it's untestable. Extract it to a utility function. Any future `DocumentStatus` addition will otherwise regress silently. --- ### Estimated CI impact | Layer | Added time | |---|---| | Unit tests | +3–5 seconds | | Integration (load functions) | +15–25 seconds | | E2E (new + modified specs) | +90–120 seconds | | Visual regression (24 snapshots) | +60–90 seconds | Total stays well within the 8-minute E2E budget. --- ### Blockers before first test can be written 1. Is `documentCount` added to `PersonDTO` or computed in the frontend load function? Determines which layer owns that test. 2. Where does the status label mapping live — inline template expression or extracted utility? If inline, that's a testability flag: extract it first. 3. Docker Compose smoke test passing before E2E work starts.
Author
Owner

Security Review — Concept A Implementation

Reviewed by: Nora "NullX" Steiner · Application Security Engineer
Scope: Existing persons surface + all proposed changes in this issue


🔴 Finding 1 — AUTHORIZATION BYPASS: Write endpoints have no permission check

Severity: High | CWE-285 (Improper Authorization)

PersonController.java has no @RequirePermission at class or method level on any mutating endpoint. Spring Security requires authentication (anyRequest().authenticated()) but that only gates identity — it does not gate write access. Any authenticated user with only READ_ALL can call these directly:

// All three are currently unprotected:
POST   /api/persons           // create person
PUT    /api/persons/{id}      // update person
POST   /api/persons/{id}/merge  // merge = deletes the source person

Fix — add @RequirePermission to each write endpoint:

@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(...) { ... }

@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(...) { ... }

@PostMapping("/{id}/merge")
@RequirePermission(Permission.WRITE_ALL)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void mergePerson(...) { ... }

🔴 Finding 2 — AUTHORIZATION BYPASS: Form actions have no permission guard

Severity: High | CWE-285

/persons/new/+page.server.ts correctly guards with a WRITE_ALL check in load(). The existing /persons/[id]/+page.server.ts has no equivalent guard on its update and merge actions — any authenticated user can POST to ?/update or ?/merge directly, bypassing the UI.

Critical for this issue: the spec moves these actions to the new /persons/[id]/edit/+page.server.ts but does not mention adding a permission guard. It will be missing unless explicitly added.

Fix — add to load() in both the existing [id]/+page.server.ts and the new /edit/+page.server.ts:

export async function load({ params, fetch, locals }) {
    const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) =>
        g.permissions.includes('WRITE_ALL')
    ) ?? false;
    if (!canWrite) throw error(403, 'Forbidden');
    // ... existing load logic
}

🟠 Finding 3 — No input length limits on string fields

Severity: Medium | CWE-400 (Uncontrolled Resource Consumption)

PersonUpdateDTO has no @Size constraints. The notes field being added to the new/edit forms is especially at risk — a write-access user could store a very large value, bloating every API response that returns a Person (search results, correspondent lists, document sender/receiver data).

Fix — add @Size + @Valid:

// PersonUpdateDTO.java
@Size(max = 100) private String firstName;
@Size(max = 100) private String lastName;
@Size(max = 200) private String alias;
@Size(max = 5000) private String notes;

Add @Valid to the updatePerson controller parameter to activate validation.


🟡 Finding 4 — No absolute range bounds on year fields

Severity: Low | Data integrity

birthYear and deathYear are cross-validated against each other but accept any integer (e.g. -9999, 99999). Both new forms surface these fields to users.

Fix — add a range check in PersonService.updatePerson:

int currentYear = java.time.Year.now().getValue();
if (dto.getBirthYear() != null && (dto.getBirthYear() < 1000 || dto.getBirthYear() > currentYear)) {
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültiges Geburtsjahr");
}

Things that are fine

  • All SQL queries in PersonRepository use named @Param bindings — no injection risk in the search or merge helpers
  • CSRF disabled with a correct, well-documented justification in SecurityConfig.java
  • mergePersons correctly rejects sourceId.equals(targetId) before touching the DB
  • The two-step merge confirmation in PersonMergePanel.svelte is a solid UX safeguard; moving it behind the Danger Zone accordion makes it even better
  • Redirect after merge is constrained to /persons/<targetPersonId> — not an open redirect

Implementation checklist

# Finding File(s)
1 Add @RequirePermission(WRITE_ALL) to write endpoints PersonController.java
2 Add WRITE_ALL guard to existing [id]/+page.server.ts and the new /edit/+page.server.ts Frontend server files
3 Add @Size constraints + @Valid PersonUpdateDTO.java, PersonController.java
4 Add year range bounds PersonService.java

Findings 1 and 2 should be fixed before or during implementation — not as follow-up issues. Finding 2 in particular is easy to introduce when creating the new edit route if the spec doesn't call it out explicitly.

## Security Review — Concept A Implementation **Reviewed by:** Nora "NullX" Steiner · Application Security Engineer **Scope:** Existing persons surface + all proposed changes in this issue --- ### 🔴 Finding 1 — AUTHORIZATION BYPASS: Write endpoints have no permission check **Severity: High | CWE-285 (Improper Authorization)** `PersonController.java` has no `@RequirePermission` at class or method level on any mutating endpoint. Spring Security requires authentication (`anyRequest().authenticated()`) but that only gates *identity* — it does not gate *write access*. Any authenticated user with only `READ_ALL` can call these directly: ```java // All three are currently unprotected: POST /api/persons // create person PUT /api/persons/{id} // update person POST /api/persons/{id}/merge // merge = deletes the source person ``` **Fix — add `@RequirePermission` to each write endpoint:** ```java @PostMapping @RequirePermission(Permission.WRITE_ALL) public ResponseEntity<Person> createPerson(...) { ... } @PutMapping("/{id}") @RequirePermission(Permission.WRITE_ALL) public ResponseEntity<Person> updatePerson(...) { ... } @PostMapping("/{id}/merge") @RequirePermission(Permission.WRITE_ALL) @ResponseStatus(HttpStatus.NO_CONTENT) public void mergePerson(...) { ... } ``` --- ### 🔴 Finding 2 — AUTHORIZATION BYPASS: Form actions have no permission guard **Severity: High | CWE-285** `/persons/new/+page.server.ts` correctly guards with a `WRITE_ALL` check in `load()`. The existing `/persons/[id]/+page.server.ts` has **no equivalent guard** on its `update` and `merge` actions — any authenticated user can POST to `?/update` or `?/merge` directly, bypassing the UI. **Critical for this issue:** the spec moves these actions to the new `/persons/[id]/edit/+page.server.ts` but does not mention adding a permission guard. It will be missing unless explicitly added. **Fix — add to `load()` in both the existing `[id]/+page.server.ts` and the new `/edit/+page.server.ts`:** ```typescript export async function load({ params, fetch, locals }) { const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL') ) ?? false; if (!canWrite) throw error(403, 'Forbidden'); // ... existing load logic } ``` --- ### 🟠 Finding 3 — No input length limits on string fields **Severity: Medium | CWE-400 (Uncontrolled Resource Consumption)** `PersonUpdateDTO` has no `@Size` constraints. The `notes` field being added to the new/edit forms is especially at risk — a write-access user could store a very large value, bloating every API response that returns a `Person` (search results, correspondent lists, document sender/receiver data). **Fix — add `@Size` + `@Valid`:** ```java // PersonUpdateDTO.java @Size(max = 100) private String firstName; @Size(max = 100) private String lastName; @Size(max = 200) private String alias; @Size(max = 5000) private String notes; ``` Add `@Valid` to the `updatePerson` controller parameter to activate validation. --- ### 🟡 Finding 4 — No absolute range bounds on year fields **Severity: Low | Data integrity** `birthYear` and `deathYear` are cross-validated against each other but accept any integer (e.g. `-9999`, `99999`). Both new forms surface these fields to users. **Fix — add a range check in `PersonService.updatePerson`:** ```java int currentYear = java.time.Year.now().getValue(); if (dto.getBirthYear() != null && (dto.getBirthYear() < 1000 || dto.getBirthYear() > currentYear)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültiges Geburtsjahr"); } ``` --- ### ✅ Things that are fine - All SQL queries in `PersonRepository` use named `@Param` bindings — no injection risk in the search or merge helpers - CSRF disabled with a correct, well-documented justification in `SecurityConfig.java` - `mergePersons` correctly rejects `sourceId.equals(targetId)` before touching the DB - The two-step merge confirmation in `PersonMergePanel.svelte` is a solid UX safeguard; moving it behind the Danger Zone accordion makes it even better - Redirect after merge is constrained to `/persons/<targetPersonId>` — not an open redirect --- ### Implementation checklist | # | Finding | File(s) | |---|---|---| | 1 | Add `@RequirePermission(WRITE_ALL)` to write endpoints | `PersonController.java` | | 2 | Add `WRITE_ALL` guard to existing `[id]/+page.server.ts` **and** the new `/edit/+page.server.ts` | Frontend server files | | 3 | Add `@Size` constraints + `@Valid` | `PersonUpdateDTO.java`, `PersonController.java` | | 4 | Add year range bounds | `PersonService.java` | Findings 1 and 2 should be fixed before or during implementation — not as follow-up issues. Finding 2 in particular is easy to introduce when creating the new edit route if the spec doesn't call it out explicitly.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#157