feat(geschichten): blog-like family memory stories (closes #381) #382

Merged
marcel merged 30 commits from feat/issue-381-geschichten into main 2026-05-04 15:02:45 +02:00
Owner

Closes #381.

Summary

Adds the Geschichten feature — blog-like family memory stories that BLOG_WRITERs can attach to historical Persons and Documents, and that all logged-in family members can read.

What's in scope

Original ACs (US-BLOG-001 through 006) plus all spec expansions:

  • Backend

    • New BLOG_WRITE permission and GESCHICHTE_NOT_FOUND error code
    • Geschichte entity (no body length limit) with two ON DELETE CASCADE join tables (geschichten_persons, geschichten_documents)
    • V58 Flyway migration with a partial index on (published_at DESC) WHERE status = 'PUBLISHED'
    • GeschichteService enforces the DRAFT-not-found rule for non-BLOG_WRITERs (404, not 403, to avoid leaking existence) and runs every save through an OWASP HTML sanitiser allow-list (p, br, strong, em, h2, h3, ul, ol, li)
    • GeschichteController with @RequirePermission(BLOG_WRITE) on every write endpoint
    • 20 service unit tests, 12 @WebMvcTest slice tests covering 401/403/200/201/204, 1 Testcontainers integration test for the full lifecycle (create draft → list-as-reader empty → publish → list-as-reader returns it → delete cascades)
  • Frontend

    • Tiptap StarterKit editor with a permanent always-visible toolbar (Bold, Italic, H2, H3, UL, OL) for the senior-author persona — 44px touch targets, aria-label and aria-pressed on every button
    • DOMPurify (via isomorphic-dompurify for SSR) on render — defence-in-depth alongside the backend sanitiser
    • /geschichten index with filter pills, ?personId and ?documentId query support
    • /geschichten/[id] detail with sanitised {@html} body, person and document chip sections, BLOG_WRITER edit/delete with confirm dialog
    • /geschichten/new and /geschichten/[id]/edit with permission guard; /new accepts ?personId and ?documentId pre-fill (silently ignored on unknown IDs to avoid leaking existence)
    • GeschichtenCard on Person detail when ≥1 published story; "Alle Geschichten zu {name}" footer link at ≥3 stories
    • DocumentMetadataDrawer switches from lg:grid-cols-3 to lg:grid-cols-4 when stories exist or the user can author; + Geschichte anhängen link visible to BLOG_WRITERs
    • New top-level Geschichten nav link in AppNav (desktop + mobile)
    • Full de / en / es Paraglide translations for every editor, index, detail, and confirmation string

Decisions encoded in the implementation

  • Tiptap is mandatory (per user instruction in /implement). StarterKit configured for B/I/¶/H2/H3/UL/OL with code, codeBlock, blockquote, strike, horizontalRule, hardBreak disabled to keep output matching the backend allow-list.
  • No body length limit (per user instruction) — no DB CHECK constraint, no character counter.
  • "Alle anzeigen" link visible at geschichten.length >= 3 (resolved by issue comment #5758).
  • Uses existing font-serif (Tinos) — Fraunces was rejected.
  • DRAFT existence is never leaked: missing/draft IDs both return GESCHICHTE_NOT_FOUND (404), not FORBIDDEN.
  • Status transitions: publishedAt is set on every entry into PUBLISHED and cleared on retract — symmetric semantics matching "story disappears from reader views immediately."

Test plan

  • cd backend && ./mvnw test — 1474 tests, 0 failures (verified locally, includes 20 + 12 + 1 Geschichte tests)
  • cd backend && ./mvnw clean package -DskipTests — verified
  • cd frontend && npm run lint — verified clean
  • cd frontend && npm run testpersons/[id]/page.server.spec.ts updated with the seventh GET mock; two pre-existing failures (confirm.svelte.test.ts, hilfe/transkription/...) unrelated to this PR
  • Smoke test the full lifecycle as admin: log in, hit /geschichten, click "Neue Geschichte", fill in title + body + a person + a document, "Entwurf speichern", refresh, "Veröffentlichen", verify the story appears on /geschichten and on the linked Person and Document pages
  • Verify + Geschichte schreiben link on /persons/{id} pre-fills the person; + Geschichte anhängen in document drawer pre-fills the document
  • Verify a READ_ALL-only user cannot reach /geschichten/{draftId} (404) and does not see DRAFTs in the index
  • Confirm the new Geschichten nav link is visible to all logged-in users

Migration impact

  • New tables: geschichten, geschichten_persons, geschichten_documents
  • Existing groups need BLOG_WRITE granted — local dev DB seeded for the Administrators and Editor groups during testing; production assignment is an admin-action follow-up

🤖 Generated with Claude Code

Closes #381. ## Summary Adds the **Geschichten** feature — blog-like family memory stories that BLOG_WRITERs can attach to historical Persons and Documents, and that all logged-in family members can read. ## What's in scope Original ACs (US-BLOG-001 through 006) **plus** all spec expansions: - **Backend** - New `BLOG_WRITE` permission and `GESCHICHTE_NOT_FOUND` error code - `Geschichte` entity (no body length limit) with two `ON DELETE CASCADE` join tables (`geschichten_persons`, `geschichten_documents`) - V58 Flyway migration with a partial index on `(published_at DESC) WHERE status = 'PUBLISHED'` - `GeschichteService` enforces the DRAFT-not-found rule for non-BLOG_WRITERs (404, not 403, to avoid leaking existence) and runs every save through an OWASP HTML sanitiser allow-list (`p, br, strong, em, h2, h3, ul, ol, li`) - `GeschichteController` with `@RequirePermission(BLOG_WRITE)` on every write endpoint - 20 service unit tests, 12 `@WebMvcTest` slice tests covering 401/403/200/201/204, 1 Testcontainers integration test for the full lifecycle (create draft → list-as-reader empty → publish → list-as-reader returns it → delete cascades) - **Frontend** - Tiptap StarterKit editor with a permanent always-visible toolbar (Bold, Italic, H2, H3, UL, OL) for the senior-author persona — 44px touch targets, `aria-label` and `aria-pressed` on every button - DOMPurify (via `isomorphic-dompurify` for SSR) on render — defence-in-depth alongside the backend sanitiser - `/geschichten` index with filter pills, `?personId` and `?documentId` query support - `/geschichten/[id]` detail with sanitised `{@html}` body, person and document chip sections, BLOG_WRITER edit/delete with confirm dialog - `/geschichten/new` and `/geschichten/[id]/edit` with permission guard; `/new` accepts `?personId` and `?documentId` pre-fill (silently ignored on unknown IDs to avoid leaking existence) - `GeschichtenCard` on Person detail when ≥1 published story; "Alle Geschichten zu {name}" footer link at ≥3 stories - `DocumentMetadataDrawer` switches from `lg:grid-cols-3` to `lg:grid-cols-4` when stories exist or the user can author; `+ Geschichte anhängen` link visible to BLOG_WRITERs - New top-level Geschichten nav link in `AppNav` (desktop + mobile) - Full `de` / `en` / `es` Paraglide translations for every editor, index, detail, and confirmation string ## Decisions encoded in the implementation - Tiptap is mandatory (per user instruction in `/implement`). StarterKit configured for B/I/¶/H2/H3/UL/OL with `code`, `codeBlock`, `blockquote`, `strike`, `horizontalRule`, `hardBreak` disabled to keep output matching the backend allow-list. - **No body length limit** (per user instruction) — no DB `CHECK` constraint, no character counter. - "Alle anzeigen" link visible at `geschichten.length >= 3` (resolved by issue comment #5758). - Uses existing `font-serif` (Tinos) — Fraunces was rejected. - DRAFT existence is never leaked: missing/draft IDs both return `GESCHICHTE_NOT_FOUND` (404), not `FORBIDDEN`. - Status transitions: `publishedAt` is set on every entry into `PUBLISHED` and cleared on retract — symmetric semantics matching "story disappears from reader views immediately." ## Test plan - [ ] `cd backend && ./mvnw test` — 1474 tests, 0 failures (verified locally, includes 20 + 12 + 1 Geschichte tests) - [ ] `cd backend && ./mvnw clean package -DskipTests` — verified - [ ] `cd frontend && npm run lint` — verified clean - [ ] `cd frontend && npm run test` — `persons/[id]/page.server.spec.ts` updated with the seventh GET mock; two pre-existing failures (`confirm.svelte.test.ts`, `hilfe/transkription/...`) unrelated to this PR - [ ] Smoke test the full lifecycle as admin: log in, hit `/geschichten`, click "Neue Geschichte", fill in title + body + a person + a document, "Entwurf speichern", refresh, "Veröffentlichen", verify the story appears on `/geschichten` and on the linked Person and Document pages - [ ] Verify `+ Geschichte schreiben` link on `/persons/{id}` pre-fills the person; `+ Geschichte anhängen` in document drawer pre-fills the document - [ ] Verify a READ_ALL-only user cannot reach `/geschichten/{draftId}` (404) and does not see DRAFTs in the index - [ ] Confirm the new Geschichten nav link is visible to all logged-in users ## Migration impact - New tables: `geschichten`, `geschichten_persons`, `geschichten_documents` - Existing groups need `BLOG_WRITE` granted — local dev DB seeded for the Administrators and Editor groups during testing; production assignment is an admin-action follow-up 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 15 commits 2026-05-02 18:15:21 +02:00
Adds OWASP Java HTML Sanitizer on the backend and DOMPurify on the frontend.
Together with Tiptap on the writer side they form a defense-in-depth chain
against XSS in the new Geschichte body field (issue #381).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation for the Geschichten (story) domain (issue #381). BLOG_WRITE gates
authoring of family memory stories; GESCHICHTE_NOT_FOUND is also returned for
DRAFTs requested by users without BLOG_WRITE so existence is not leaked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Geschichte holds family memory stories (issue #381). Body is unbounded TEXT
(Tiptap HTML, no length limit). Two join tables link a story to historical
Persons and Documents. A partial index speeds the public index query
(status='PUBLISHED' ORDER BY published_at DESC) and reverse-lookup indexes
support the ?personId and ?documentId filters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GeschichteRepository.search filters by status / personId / documentId in a
single JPQL query so the controller can serve the index page, the person
discovery card, and the document drawer column from one method. The DTO is
shared between create and update like DocumentUpdateDTO.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DRAFT stories are 404 to readers without BLOG_WRITE (NOT_FOUND, not FORBIDDEN,
to avoid leaking existence). list() forces status=PUBLISHED for non-writers
even when they pass status=null. Body HTML is sanitised via OWASP allow-list
(p, br, strong, em, h2, h3, ul, ol, li) on every save. publishedAt is set on
every transition into PUBLISHED and cleared on retract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET endpoints are open to authenticated users (the service layer enforces
DRAFT visibility). POST/PATCH/DELETE require @RequirePermission(BLOG_WRITE).
WebMvcTest slice covers 401/403/200/201/204 paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The end-to-end test creates a DRAFT, verifies it is hidden from a READ_ALL
reader (list and getById), publishes it, verifies the reader sees it, then
deletes it and confirms the join rows go with it but the linked Person
remains. Also corrects the V58 author FK to reference the actual users
table (not app_users).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
  for the Geschichten index, detail, editor, and confirmation copy in
  de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
  matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
  plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
  prop type-checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors PersonMultiSelect for documents: chip-style multi-select backed by
GET /api/documents/search?q=. Used in the Geschichte editor sidebar to link
referenced documents to a story.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap StarterKit configured for B/I/¶/H2/H3/UL/OL/history; code, codeBlock,
blockquote, strike, horizontalRule and hardBreak disabled to keep output
matching the backend HTML allow-list. Two-column responsive layout with the
editor body on the left and Personen / Dokumente / Status sections in the
sidebar. Sticky save bar adapts to DRAFT vs PUBLISHED state. Title-required
guard with inline error and beforeNavigate dirty-state guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stripHtml() strips tags via DOMParser (browser) with a regex fallback for
SSR. plainExcerpt() truncates at a word boundary with an ellipsis. Both
covered by Vitest specs.

GeschichtenCard renders the top 3 published stories about a person on
/persons/[id], with an editorial excerpt, publication date, author, and a
"+ Geschichte schreiben" link visible only to BLOG_WRITERs. Footer link to
/geschichten?personId=... appears once geschichten.length >= 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /geschichten — published-stories index with filter pills + "+ Neue Geschichte"
  for BLOG_WRITERs; supports ?personId and ?documentId pre-filtering
- /geschichten/[id] — reader detail with sanitised {@html} body, person and
  document chip sections, BLOG_WRITER edit/delete with confirm dialog
- /geschichten/new — editor with optional ?personId and ?documentId pre-fill
  (silent ignore on unknown IDs to avoid leaking entity existence)
- /geschichten/[id]/edit — editor populated from existing story; BLOG_WRITE
  guard redirects readers to the detail page

All routes load via createApiClient(fetch) with !response.ok error handling
following the project pattern; PATCH/DELETE go through raw fetch which the
Vite dev proxy / Caddy production proxy authenticates via cookie.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
  in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
  person has at least one published story.

Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
  to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
  Geschichten column should appear (stories exist OR user can author),
  and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
  >= 3-story threshold from issue comment #5758.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test(persons): add seventh GET mock for the geschichten API call
Some checks failed
CI / Backend Unit Tests (push) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 4m56s
CI / OCR Service Tests (push) Successful in 50s
CI / Unit & Component Tests (pull_request) Failing after 3m51s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
b698f9f223
The /persons/[id] +page.server.ts now fetches geschichten in parallel with
the other endpoints. Each test in this spec mocks the typed-client's GET
call sequentially, so each chain needs one extra resolved value.

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

🏛️ Markus Keller — Application Architect

Verdict: 🚫 Changes requested

Blockers

B1. Admin UI does not expose the new BLOG_WRITE permission.
The standard-permission checkbox list in frontend/src/routes/admin/groups/[id]/+page.svelte:27-31 and frontend/src/routes/admin/groups/new/+page.svelte:7-9 is hardcoded as:

{ value: 'READ_ALL', ... },
{ value: 'ANNOTATE_ALL', ... },
{ value: 'WRITE_ALL', ... }

A new BLOG_WRITE is silently un-grantable through the UI. The PR description even notes "production groups need BLOG_WRITE granted as an admin action" — that admin action does not exist. Add BLOG_WRITE to STANDARD_PERMISSIONS (with a Paraglide label admin_perm_blog_write) on both pages, and to the group-editor unit tests.

B2. Geschichte.body is potentially huge and is EAGER-loaded transitively through every list query.
The entity declares @ManyToMany(fetch = FetchType.EAGER) on both persons and documents (backend/src/main/java/.../model/Geschichte.java:46-58). For the index page that lists 50 stories, every story drags its full person+document graph into the result set. With 200 stories and tagged documents this is a real cliff. Consider LAZY everywhere on Geschichte and project a GeschichteSummary DTO for list responses (id, title, author, publishedAt, persons[id+displayName], documents[id+title+date]). The Document payload is especially heavy — Jackson serialises ~30 fields per row.

Suggestions

  • Layer-vs-feature packaging: my earlier review (#5744) flagged that a geschichten/ feature package would be the right shape; this PR follows the existing layer-first convention (controller/, service/, model/). I'm not blocking — consistency with the existing codebase is fine for now. But if/when we extract a feature package per domain, Geschichten is the cheapest one to move first.
  • Author FK now ON DELETE SET NULL: good. Stories outlive their authors, which matches the family-archive philosophy.
  • GeschichteRepository.search uses one JPQL query for status / personId / documentId: clean, exactly what I asked for in #5751. Approved.
  • V58 partial index (published_at DESC) WHERE status='PUBLISHED': present and correct. The reverse-lookup join-table indexes are also there. Good.
  • No ADR for the OWASP Java HTML Sanitizer dependency or the 9-tag allow-list. Not blocking, but worth one paragraph in docs/adr/ so the next reviewer doesn't undo the allow-list "to support inline images." Reference: Nora's defence-in-depth chain.
## 🏛️ Markus Keller — Application Architect **Verdict: 🚫 Changes requested** ### Blockers **B1. Admin UI does not expose the new `BLOG_WRITE` permission.** The standard-permission checkbox list in `frontend/src/routes/admin/groups/[id]/+page.svelte:27-31` and `frontend/src/routes/admin/groups/new/+page.svelte:7-9` is hardcoded as: ``` { value: 'READ_ALL', ... }, { value: 'ANNOTATE_ALL', ... }, { value: 'WRITE_ALL', ... } ``` A new `BLOG_WRITE` is silently un-grantable through the UI. The PR description even notes "production groups need `BLOG_WRITE` granted as an admin action" — that admin action does not exist. Add `BLOG_WRITE` to `STANDARD_PERMISSIONS` (with a Paraglide label `admin_perm_blog_write`) on both pages, and to the group-editor unit tests. **B2. `Geschichte.body` is potentially huge and is `EAGER`-loaded transitively through every list query.** The entity declares `@ManyToMany(fetch = FetchType.EAGER)` on both `persons` and `documents` (`backend/src/main/java/.../model/Geschichte.java:46-58`). For the index page that lists 50 stories, every story drags its full person+document graph into the result set. With 200 stories and tagged documents this is a real cliff. Consider `LAZY` everywhere on Geschichte and project a `GeschichteSummary` DTO for list responses (id, title, author, publishedAt, persons[id+displayName], documents[id+title+date]). The `Document` payload is especially heavy — Jackson serialises ~30 fields per row. ### Suggestions - **Layer-vs-feature packaging:** my earlier review (#5744) flagged that a `geschichten/` feature package would be the right shape; this PR follows the existing layer-first convention (`controller/`, `service/`, `model/`). I'm not blocking — consistency with the existing codebase is fine for now. But if/when we extract a feature package per domain, Geschichten is the cheapest one to move first. - **Author FK now `ON DELETE SET NULL`:** good. Stories outlive their authors, which matches the family-archive philosophy. - **`GeschichteRepository.search` uses one JPQL query for status / personId / documentId:** clean, exactly what I asked for in #5751. Approved. - **V58 partial index `(published_at DESC) WHERE status='PUBLISHED'`:** present and correct. The reverse-lookup join-table indexes are also there. Good. - **No ADR for the OWASP Java HTML Sanitizer dependency or the 9-tag allow-list.** Not blocking, but worth one paragraph in `docs/adr/` so the next reviewer doesn't undo the allow-list "to support inline images." Reference: Nora's defence-in-depth chain.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

B1. GeschichteEditor.svelte skips the red phase for the toolbar and save-bar logic.
The component file lands at +329 lines with no Vitest spec next to it. The plan I posted in #5752 explicitly listed GeschichteEditorTest covering save-bar state derived from status, pre-fill from props, and the title-required guard. The patch shipped with no component test at all. Concrete asks (one spec each is enough):

  • title-empty disables both DRAFT and PUBLISHED save buttons + shows the inline error after blur
  • DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"; PUBLISHED renders "Speichern" + "Zurück zu Entwurf"
  • initialPersons and initialDocuments props render as chips on first paint
  • onSubmit is invoked with the trimmed title and the right status

These are 30-line tests; their absence makes the editor untouchable for the next dev.

B2. DocumentMultiSelect.svelte ships without a spec while PersonTypeahead.svelte has one. Symmetry: copy PersonTypeahead.svelte.spec.ts and rewire to the documents endpoint. At minimum: open dropdown, fetch debounce, select-adds-chip, remove-chip.

Suggestions

  • State-referenced-locally warnings on GeschichteEditor.svelte:41-48$state(geschichte?.title ?? '') snapshots the prop at mount and the warnings are intentional (the parent re-mounts to reset). Add a one-line // initial-snapshot from props; parent re-mounts to reset comment so the next reader doesn't try to "fix" it with $derived.
  • title.trim() runs on every save but the dirty flag never differentiates "title changed" from "body changed" — minor. Not worth a refactor.
  • Save bar: PUBLISHED-mode "Speichern" button is disabled when titleEmpty — but the title comes from a published story, so it's never empty in practice. Fine.
  • TDD discipline on the backend was excellent — 20 service tests written red→green, 12 controller slice tests, 1 Testcontainers integration. The frontend is the only part where TDD slipped.
  • Commit hygiene is excellent — 16 atomic commits, each one logically separable. This is exactly the merge train I'd want to bisect against.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers **B1. `GeschichteEditor.svelte` skips the red phase for the toolbar and save-bar logic.** The component file lands at +329 lines with no Vitest spec next to it. The plan I posted in #5752 explicitly listed `GeschichteEditorTest` covering save-bar state derived from `status`, pre-fill from props, and the title-required guard. The patch shipped with no component test at all. Concrete asks (one spec each is enough): - title-empty disables both DRAFT and PUBLISHED save buttons + shows the inline error after blur - DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"; PUBLISHED renders "Speichern" + "Zurück zu Entwurf" - `initialPersons` and `initialDocuments` props render as chips on first paint - `onSubmit` is invoked with the trimmed title and the right `status` These are 30-line tests; their absence makes the editor untouchable for the next dev. **B2. `DocumentMultiSelect.svelte` ships without a spec while `PersonTypeahead.svelte` has one.** Symmetry: copy `PersonTypeahead.svelte.spec.ts` and rewire to the documents endpoint. At minimum: open dropdown, fetch debounce, select-adds-chip, remove-chip. ### Suggestions - **State-referenced-locally warnings on `GeschichteEditor.svelte:41-48`** — `$state(geschichte?.title ?? '')` snapshots the prop at mount and the warnings are intentional (the parent re-mounts to reset). Add a one-line `// initial-snapshot from props; parent re-mounts to reset` comment so the next reader doesn't try to "fix" it with `$derived`. - **`title.trim()` runs on every save** but the `dirty` flag never differentiates "title changed" from "body changed" — minor. Not worth a refactor. - **Save bar: PUBLISHED-mode "Speichern" button is disabled when `titleEmpty`** — but the title comes from a published story, so it's never empty in practice. Fine. - **TDD discipline on the backend was excellent** — 20 service tests written red→green, 12 controller slice tests, 1 Testcontainers integration. The frontend is the only part where TDD slipped. - **Commit hygiene is excellent** — 16 atomic commits, each one logically separable. This is exactly the merge train I'd want to bisect against.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Verdict: ⚠️ Approved with concerns

Defence-in-depth chain — confirmed

The full XSS chain is in place:

  1. Tiptap on input (@tiptap/starter-kit 3.22.5) — controls the input pipeline, no contenteditable paste-injection vector.
  2. OWASP Java HTML Sanitizer on save (GeschichteService.BODY_SANITIZER, service/GeschichteService.java:42-46) — allow-list of 9 tags applied inside both create() and update().
  3. DOMPurify on render via safeHtml() (frontend/src/lib/utils/sanitize.ts) — applied to {@html sanitized} in the detail page (routes/geschichten/[id]/+page.svelte:63).

The integration test confirms this round-trips correctly — the smoke run above stripped <script>alert('xss')</script> from a real persisted story.

Blockers

B1. The safeHtml() wrapper is bypassed on /geschichten index card excerpts and on the GeschichtenCard person-page integration.
Both list views call plainExcerpt(g.body, 80) (frontend/src/lib/utils/stripHtml.ts). stripHtml() falls back to a regex replace(/<[^>]*>/g, '') on the server when DOMParser is undefined. That regex is not an XSS sanitiser — it leaves angle-bracket-less injection vectors and HTML entities like &amp;lt;script&amp;gt; untouched if the body ever contained them. Today the body is sanitised on save, so the index excerpts are safe in practice. But the comment on stripHtml.ts:8-12 does not say "this is not a security control" — and a future engineer will reach for it. Concrete fix: rename stripHtmlextractText, document at the top "Not a sanitizer. Only safe for sanitised input.", and add a Vitest case asserting extractText('<script>alert(1)</script>') returns either alert(1) (text content extraction) or — preferably — empty. The regex-fallback should also normalise whitespace.

Suggestions

S1. GeschichteService.currentUser() calls userService.findByEmail(auth.getName()) and stores the result on the entity. If the user is deleted between authentication and the next save (extremely rare, but possible), findByEmail may return null and g.setAuthor(null) will silently succeed with no author. The DB FK is ON DELETE SET NULL, so this is consistent — but the service has no guard and no log. Add if (user == null) throw DomainException.unauthorized("...") so we fail closed.

S2. The PATCH endpoint has no idempotency / version field. Two BLOG_WRITERs editing the same story will race; last-writer-wins. Sara flagged this in #5747 and accepted it as out-of-scope; I'd add a @Version column in V59 before this becomes a real problem (which won't be soon — small family archive). Not a blocker.

S3. fetch('/api/geschichten/...') in the editor and detail pages — these are client-side calls bypassing createApiClient(). Per frontend/CLAUDE.md the proxy injects the cookie, so auth still works in dev (Vite proxy) and prod (Caddy). But this leaks the API path into the browser bundle and breaks SSR if anyone ever reads the response in +page.server.ts. Felix's PersonMentionEditor has the same trade-off and is documented. Add the same comment block on the editor pages explaining why this is the right call. Not a blocker.

S4. frontend/src/lib/utils/sanitize.spec.ts uses isomorphic-dompurify in the Node test environment — good. But the tests cover only the happy paths I'd write myself. Add one of the OWASP XSS Cheat Sheet's classic payloads as a regression: <svg/onload=alert(1)>, <iframe srcdoc="<script>alert(1)</script>">, and <a href="javascript:alert(1)">x</a>.

## 🔒 Nora "NullX" Steiner — Security Engineer **Verdict: ⚠️ Approved with concerns** ### Defence-in-depth chain — confirmed The full XSS chain is in place: 1. **Tiptap on input** (`@tiptap/starter-kit` 3.22.5) — controls the input pipeline, no `contenteditable` paste-injection vector. 2. **OWASP Java HTML Sanitizer on save** (`GeschichteService.BODY_SANITIZER`, `service/GeschichteService.java:42-46`) — allow-list of 9 tags applied inside both `create()` and `update()`. 3. **DOMPurify on render** via `safeHtml()` (`frontend/src/lib/utils/sanitize.ts`) — applied to `{@html sanitized}` in the detail page (`routes/geschichten/[id]/+page.svelte:63`). The integration test confirms this round-trips correctly — the smoke run above stripped `<script>alert('xss')</script>` from a real persisted story. ✅ ### Blockers **B1. The `safeHtml()` wrapper is bypassed on `/geschichten` index card excerpts and on the `GeschichtenCard` person-page integration.** Both list views call `plainExcerpt(g.body, 80)` (`frontend/src/lib/utils/stripHtml.ts`). `stripHtml()` falls back to a regex `replace(/<[^>]*>/g, '')` on the server when `DOMParser` is undefined. That regex is **not** an XSS sanitiser — it leaves angle-bracket-less injection vectors and HTML entities like `&amp;lt;script&amp;gt;` untouched if the body ever contained them. Today the body is sanitised on save, so the index excerpts are safe in practice. But the comment on `stripHtml.ts:8-12` does not say "this is not a security control" — and a future engineer will reach for it. Concrete fix: rename `stripHtml` → `extractText`, document at the top "**Not a sanitizer.** Only safe for sanitised input.", and add a Vitest case asserting `extractText('<script>alert(1)</script>')` returns either `alert(1)` (text content extraction) or — preferably — empty. The regex-fallback should also normalise whitespace. ### Suggestions **S1. `GeschichteService.currentUser()` calls `userService.findByEmail(auth.getName())` and stores the result on the entity.** If the user is deleted between authentication and the next save (extremely rare, but possible), `findByEmail` may return null and `g.setAuthor(null)` will silently succeed with no author. The DB FK is `ON DELETE SET NULL`, so this is consistent — but the service has no guard and no log. Add `if (user == null) throw DomainException.unauthorized("...")` so we fail closed. **S2. The PATCH endpoint has no idempotency / version field.** Two BLOG_WRITERs editing the same story will race; last-writer-wins. Sara flagged this in #5747 and accepted it as out-of-scope; I'd add a `@Version` column in V59 before this becomes a real problem (which won't be soon — small family archive). Not a blocker. **S3. `fetch('/api/geschichten/...')` in the editor and detail pages** — these are client-side calls bypassing `createApiClient()`. Per `frontend/CLAUDE.md` the proxy injects the cookie, so auth still works in dev (Vite proxy) and prod (Caddy). But this leaks the API path into the browser bundle and breaks SSR if anyone ever reads the response in `+page.server.ts`. Felix's `PersonMentionEditor` has the same trade-off and is documented. Add the same comment block on the editor pages explaining why this is the right call. Not a blocker. **S4. `frontend/src/lib/utils/sanitize.spec.ts` uses `isomorphic-dompurify` in the Node test environment** — good. But the tests cover only the happy paths I'd write myself. Add one of the OWASP XSS Cheat Sheet's classic payloads as a regression: `<svg/onload=alert(1)>`, `<iframe srcdoc="<script>alert(1)</script>">`, and `<a href="javascript:alert(1)">x</a>`.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: 🚫 Changes requested

Test pyramid summary

Layer Where Status
Unit GeschichteServiceTest (20), sanitize.spec.ts (5), stripHtml.spec.ts (6)
Slice GeschichteControllerTest @WebMvcTest (12)
Integration GeschichteServiceIntegrationTest Testcontainers
Component GeschichteEditor, GeschichtenCard, DocumentMultiSelect none
E2E Playwright explicitly skipped

Blockers

B1. No Playwright e2e covering the writer or reader journey. The PR description acknowledges this ("scoped out for this delivery") but the journeys are exactly the kind of cross-cutting flow that the existing 37 e2e specs catch. At minimum I want one spec — geschichten.spec.ts — covering:

test.describe('Geschichten', () => {
  test('writer journey: create draft → publish → reader sees it', ...)
  test('reader cannot fetch a DRAFT by direct URL (404)', ...)
  test('person discovery: card on /persons/[id] links to story', ...)
  test('document drawer Geschichten column expands to 4 columns', ...)
  test('a11y: /geschichten and /geschichten/[id] pass AxeBuilder wcag2aa', ...)
})

The Axe check is a hard gate per frontend/CLAUDE.md. Without it we ship a feature whose accessibility is unverified at the layer that matters most — the rendered DOM.

B2. The two pre-existing test failures (confirm.svelte.test.ts, hilfe/transkription/page.svelte.spec.ts) are noted as out-of-scope. They are — but the PR's own commit 9e7861fa modified frontend/src/routes/admin/users/new/page.svelte.spec.ts to add the canBlogWrite field. If that change were applied to all layout-data mock objects, several of the long-tail TS errors in npm run check would also clear. The PR shipped with a baseline of 261 type errors (verified by your stash diff). Open a follow-up issue to drive that count down — every PR that touches +layout.server.ts adds churn here.

Suggestions

S1. GeschichteServiceIntegrationTest.create_then_publish_then_read_then_delete_full_lifecycle is one giant test. Sara-rule: one logical assertion per test. Split into:

should_hide_DRAFT_from_READ_ALL_user_in_list
should_404_DRAFT_to_READ_ALL_user_on_getById
should_set_publishedAt_when_DRAFT_publishes
should_cascade_join_table_rows_on_delete
should_leave_referenced_Person_intact_on_Geschichte_delete

When this test fails in 6 months, we'll want to know which behaviour broke without reading the test body.

S2. GeschichteControllerTest uses @MockitoBean GeschichteService geschichteService and a hand-rolled ObjectMapper. The ObjectMapper workaround is fine, but worth a comment so the next person knows why @Autowired ObjectMapper doesn't work in @WebMvcTest (no JacksonAutoConfiguration because the slice doesn't include the full web context).

S3. persons/[id]/page.server.spec.ts now has 7 sequential .mockResolvedValueOnce calls per test. This is exactly the brittle pattern that breaks on order changes. Switch to mock-by-URL:

const get = vi.fn().mockImplementation((url, opts) => {
  if (url === '/api/geschichten') return { response: { ok: true }, data: [] };
  if (url === '/api/persons/{id}') return { response: { ok: true, status: 200 }, data: makePerson() };
  // ...
});

Order-independent, easier to read, robust to future Promise.all reordering.

S4. Demo data is in (verified the smoke output above) but is not seeded in e2e profile. Add a data.sql seed in backend/src/main/resources/db/migration/e2e/ so e2e tests have predictable Geschichten without manual setup.

Approved

  • Backend test pyramid is solid — 33 new tests, three layers, all green.
  • Permission boundary tests (401/403/200/201/204) are explicit per @RequirePermission enforcement. Exactly right.
  • DRAFT-as-NOT-FOUND is tested at both layers.
## 🧪 Sara Holt — QA Engineer **Verdict: 🚫 Changes requested** ### Test pyramid summary | Layer | Where | Status | |---|---|---| | Unit | `GeschichteServiceTest` (20), `sanitize.spec.ts` (5), `stripHtml.spec.ts` (6) | ✅ | | Slice | `GeschichteControllerTest` `@WebMvcTest` (12) | ✅ | | Integration | `GeschichteServiceIntegrationTest` Testcontainers | ✅ | | Component | `GeschichteEditor`, `GeschichtenCard`, `DocumentMultiSelect` | ❌ none | | E2E | Playwright | ❌ explicitly skipped | ### Blockers **B1. No Playwright e2e covering the writer or reader journey.** The PR description acknowledges this ("scoped out for this delivery") but the journeys are exactly the kind of cross-cutting flow that the existing 37 e2e specs catch. At minimum I want one spec — `geschichten.spec.ts` — covering: ``` test.describe('Geschichten', () => { test('writer journey: create draft → publish → reader sees it', ...) test('reader cannot fetch a DRAFT by direct URL (404)', ...) test('person discovery: card on /persons/[id] links to story', ...) test('document drawer Geschichten column expands to 4 columns', ...) test('a11y: /geschichten and /geschichten/[id] pass AxeBuilder wcag2aa', ...) }) ``` The Axe check is a hard gate per `frontend/CLAUDE.md`. Without it we ship a feature whose accessibility is unverified at the layer that matters most — the rendered DOM. **B2. The two pre-existing test failures (`confirm.svelte.test.ts`, `hilfe/transkription/page.svelte.spec.ts`) are noted as out-of-scope.** They are — but the PR's own commit `9e7861fa` modified `frontend/src/routes/admin/users/new/page.svelte.spec.ts` to add the `canBlogWrite` field. If that change were applied to **all** layout-data mock objects, several of the long-tail TS errors in `npm run check` would also clear. The PR shipped with a baseline of 261 type errors (verified by your stash diff). Open a follow-up issue to drive that count down — every PR that touches `+layout.server.ts` adds churn here. ### Suggestions **S1. `GeschichteServiceIntegrationTest.create_then_publish_then_read_then_delete_full_lifecycle` is one giant test.** Sara-rule: one logical assertion per test. Split into: ``` should_hide_DRAFT_from_READ_ALL_user_in_list should_404_DRAFT_to_READ_ALL_user_on_getById should_set_publishedAt_when_DRAFT_publishes should_cascade_join_table_rows_on_delete should_leave_referenced_Person_intact_on_Geschichte_delete ``` When this test fails in 6 months, we'll want to know which behaviour broke without reading the test body. **S2. `GeschichteControllerTest` uses `@MockitoBean GeschichteService geschichteService` and a hand-rolled `ObjectMapper`.** The `ObjectMapper` workaround is fine, but worth a comment so the next person knows why `@Autowired ObjectMapper` doesn't work in `@WebMvcTest` (no `JacksonAutoConfiguration` because the slice doesn't include the full web context). **S3. `persons/[id]/page.server.spec.ts` now has 7 sequential `.mockResolvedValueOnce` calls per test.** This is exactly the brittle pattern that breaks on order changes. Switch to mock-by-URL: ```typescript const get = vi.fn().mockImplementation((url, opts) => { if (url === '/api/geschichten') return { response: { ok: true }, data: [] }; if (url === '/api/persons/{id}') return { response: { ok: true, status: 200 }, data: makePerson() }; // ... }); ``` Order-independent, easier to read, robust to future Promise.all reordering. **S4. Demo data is in (verified the smoke output above) but is not seeded in `e2e` profile.** Add a `data.sql` seed in `backend/src/main/resources/db/migration/e2e/` so e2e tests have predictable Geschichten without manual setup. ### Approved - Backend test pyramid is solid — 33 new tests, three layers, all green. - Permission boundary tests (401/403/200/201/204) are explicit per `@RequirePermission` enforcement. Exactly right. - DRAFT-as-NOT-FOUND is tested at both layers.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

What works

  • Toolbar buttons are 44×44 (h-11 min-w-[44px]) and always visible with no overflow menu — exactly what the senior-author persona needs. Each button has both aria-label and aria-pressed. Excellent.
  • Editor uses semantic markup<article>, proper heading hierarchy, role="textbox" aria-multiline="true" on the Tiptap surface, aria-invalid/aria-describedby wired to the title-required error. Accessibility hygiene is genuinely above average for the codebase.
  • Filter pills carry aria-pressed on the /geschichten index — the "Alle" pill correctly switches between true and false when a person filter is active.
  • Confirmation dialog re-uses getConfirmService() and is destructive=true for delete. Correct pattern.

Blockers

B1. Story body uses prose font-serif text-lg (routes/geschichten/[id]/+page.svelte:61)text-lg = 18px, fine. But the editor's body uses font-serif text-base (16px) and the prose typography plugin defaults to a max-width that can hard-clamp lines to ~65ch. The story-detail content is centred in a max-w-3xl (~768px) parent and the prose class adds another inner constraint — readers will see a narrow column inside an already narrow page. Either drop prose and style explicitly, or use prose-lg max-w-none so the parent's width is the only constraint.

B2. The GeschichtenCard heading on Person detail uses text-xs font-bold tracking-widest text-ink-3 uppercase — that's ~10–11px after the tracking and the text-ink-3 colour is #6b7280 on white = 4.8:1. Right at the AA floor for normal text but failing AAA. Headings count as normal text below 14pt. Make this text-ink-2 (#4b5563 = 7.6:1) so the senior persona can read it under bright daylight on a tablet. Same fix needed for every "section header" reuse pattern in this PR.

Suggestions

S1. The /geschichten/new and /geschichten/[id]/edit page headings (text-3xl) sit above the editor with no breadcrumb. A reader who lands on edit-mode via a deep link has no path back other than BackButton (history.back()). Add a <a href="/geschichten">Geschichten</a> / <a href="/geschichten/[id]">Story title</a> / Bearbeiten breadcrumb above the H1 — <nav aria-label="Breadcrumb"> per WAI-ARIA.

S2. Toolbar buttons render letterforms (B, I, H2, H3, , 1.). Seniors recognise B and I from email clients but H2/H3 are jargon. Either:

  • use the Paraglide labels as visible text below 768px (hidden sm:block on the icon, block sm:hidden on the label), or
  • replace H2 with Aa and H3 with Aa (smaller) — visual-hierarchy cue without jargon.

The aria-label is correct; this is about visible text for sighted seniors.

S3. The save-bar shows status hint text (text-xs text-ink-3) on the left side. In a sticky bar at the bottom of a tall editor, that hint is below the keyboard area on mobile and effectively invisible during typing. Consider moving the hint above the editor (at the top, below the H1) so it's always in view.

S4. The Person detail integration card and the Document drawer column both use text-ink/60 for the + Geschichte schreiben link. That's ~70% lightness on white — borderline AA. Use text-ink-2 for the same sand/grey effect with passable contrast.

S5. No reduced-motion handling on the new transition:slide already used in DocumentTopBar.svelte:288 — pre-existing, not introduced here, just flagging that the Geschichten drawer column will inherit it.

S6. Empty state on /geschichten — the message "Es gibt noch keine veröffentlichten Geschichten." is centred in a card. Good. But there's no call-to-action for BLOG_WRITERs. Add a "Neue Geschichte" button below the empty-state text when canBlogWrite — the new-button in the header is small and easy to miss.

A11y verdict

I cannot run AxeBuilder myself here, but the markup I read is mostly clean. Sara's blocker on adding e2e a11y checks is the right gate.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** ### What works - **Toolbar buttons are 44×44** (`h-11 min-w-[44px]`) and **always visible** with no overflow menu — exactly what the senior-author persona needs. Each button has both `aria-label` and `aria-pressed`. Excellent. - **Editor uses semantic markup** — `<article>`, proper heading hierarchy, `role="textbox" aria-multiline="true"` on the Tiptap surface, `aria-invalid`/`aria-describedby` wired to the title-required error. Accessibility hygiene is genuinely above average for the codebase. - **Filter pills carry `aria-pressed`** on the `/geschichten` index — the "Alle" pill correctly switches between `true` and `false` when a person filter is active. - **Confirmation dialog re-uses `getConfirmService()`** and is destructive=true for delete. Correct pattern. ### Blockers **B1. Story body uses `prose font-serif text-lg` (`routes/geschichten/[id]/+page.svelte:61`)** — `text-lg` = 18px, fine. But the editor's body uses `font-serif text-base` (16px) and the `prose` typography plugin defaults to a max-width that can hard-clamp lines to ~65ch. The story-detail content is centred in a `max-w-3xl` (~768px) parent **and** the `prose` class adds another inner constraint — readers will see a narrow column inside an already narrow page. Either drop `prose` and style explicitly, or use `prose-lg max-w-none` so the parent's width is the only constraint. **B2. The `GeschichtenCard` heading on Person detail uses `text-xs font-bold tracking-widest text-ink-3 uppercase` — that's ~10–11px after the tracking and the `text-ink-3` colour is **#6b7280** on white = 4.8:1.** Right at the AA floor for normal text but failing AAA. Headings count as normal text below 14pt. Make this `text-ink-2` (#4b5563 = 7.6:1) so the senior persona can read it under bright daylight on a tablet. Same fix needed for every "section header" reuse pattern in this PR. ### Suggestions **S1. The `/geschichten/new` and `/geschichten/[id]/edit` page headings (`text-3xl`) sit above the editor with no breadcrumb.** A reader who lands on edit-mode via a deep link has no path back other than `BackButton` (history.back()). Add a `<a href="/geschichten">Geschichten</a> / <a href="/geschichten/[id]">Story title</a> / Bearbeiten` breadcrumb above the H1 — `<nav aria-label="Breadcrumb">` per WAI-ARIA. **S2. Toolbar buttons render letterforms (`B`, `I`, `H2`, `H3`, `•`, `1.`).** Seniors recognise `B` and `I` from email clients but `H2`/`H3` are jargon. Either: - use the Paraglide labels as visible text below 768px (`hidden sm:block` on the icon, `block sm:hidden` on the label), or - replace `H2` with `Aa` and `H3` with `Aa` (smaller) — visual-hierarchy cue without jargon. The `aria-label` is correct; this is about visible text for sighted seniors. **S3. The save-bar shows status hint text (`text-xs text-ink-3`) on the left side.** In a sticky bar at the bottom of a tall editor, that hint is below the keyboard area on mobile and effectively invisible during typing. Consider moving the hint above the editor (at the top, below the H1) so it's always in view. **S4. The Person detail integration card and the Document drawer column both use `text-ink/60` for the `+ Geschichte schreiben` link.** That's ~70% lightness on white — borderline AA. Use `text-ink-2` for the same sand/grey effect with passable contrast. **S5. No reduced-motion handling on the new `transition:slide` already used in `DocumentTopBar.svelte:288`** — pre-existing, not introduced here, just flagging that the Geschichten drawer column will inherit it. **S6. Empty state on `/geschichten` — the message "Es gibt noch keine veröffentlichten Geschichten." is centred in a card. Good. But there's no call-to-action for BLOG_WRITERs.** Add a "Neue Geschichte" button below the empty-state text when `canBlogWrite` — the new-button in the header is small and easy to miss. ### A11y verdict I cannot run AxeBuilder myself here, but the markup I read is mostly clean. Sara's blocker on adding e2e a11y checks is the right gate.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: ⚠️ Approved with concerns

What works

  • No infrastructure changes. No new services, ports, volumes, env vars, or images. The PR is entirely in-process — exactly right for a feature this size.
  • V58 migration applies cleanly against postgres:16-alpine (verified in the integration test run, 56 migrations applied, ~378ms total).
  • docker compose up -d --build backend rebuilds and restarts cleanly — verified during the implementation run; the new endpoints showed up in the OpenAPI spec immediately.
  • OWASP Java HTML Sanitizer (com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1) — pinned version, mature artifact, no security advisories. Good choice.
  • isomorphic-dompurify brings JSDOM as a transitive dependency for SSR sanitisation. Heavier than I'd like but it's the cleanest path.

Blockers

B1. frontend/yarn.lock is committed alongside frontend/package-lock.json. Both lockfiles get updated when npm install runs. This is a long-standing repo wart and not introduced here, but the PR adds 16 lines to yarn.lock and 23 to package-lock.json for the same dependency change. Pick one. The repo runs npm everywhere (CI, docker, dev). Delete yarn.lock in a follow-up commit and add it to .gitignore.

Suggestions

S1. Container node_modules volume vs host install. During implementation we hit Failed to resolve import "isomorphic-dompurify" — the host npm install updated package.json and the host node_modules, but the running container has its own frontend_node_modules:/app/node_modules volume that didn't see the new package until docker exec archive-frontend npm install was run. This is a known dev-environment foot-gun. Worth a paragraph in docs/DEVELOPING.md (or wherever the dev setup lives): after npm install on the host, restart or npm install inside the container too. Or change the dev compose to npm install on every container start (slower boot, fewer support tickets).

S2. Backend rebuild took ~3 minutes (Maven compile + Docker build) and the Surefire test run took ~16s for the full 1474-test suite. This is fine, but the new GeschichteServiceIntegrationTest brings up a fresh Postgres container per test (it's a single-test class, so just once). If we add more integration tests across other services, container startup will dominate. Consider @Testcontainers(disabledWithoutDocker = true) already in the test config + --reuse mode for the Postgres container in dev to amortise startup.

S3. The /api/geschichten endpoints expose unbounded body text in JSON responses. No gzip config in Caddy is referenced in the PR — confirm the production reverse proxy applies encode gzip zstd (it should already; just flagging that long story bodies are the kind of payload where compression matters, especially on mobile).

S4. No metrics tag for /api/geschichten. Spring Actuator's http.server.requests metric will pick it up automatically (Micrometer). Grafana panels may need a manual refresh of the URI label list to make the new endpoints filterable. No code change needed.

S5. BLOG_WRITE granted on the local dev DB only — the PR description acknowledges production groups need it granted manually. Add a one-time migration V59__seed_blog_write_for_admins.sql or a startup data initializer that grants BLOG_WRITE to whichever group already holds WRITE_ALL. Otherwise the feature ships dark — admins won't see the editor on prod day-one.

LGTM (where it counts)

  • Image tags pinned, no :latest, no new exposed ports.
  • Docker network, S3, MinIO, OCR service untouched.
  • Migration versioning (V58) is correct and forward-only.
## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ⚠️ Approved with concerns** ### What works - **No infrastructure changes.** No new services, ports, volumes, env vars, or images. The PR is entirely in-process — exactly right for a feature this size. - **V58 migration applies cleanly** against `postgres:16-alpine` (verified in the integration test run, 56 migrations applied, ~378ms total). - **`docker compose up -d --build backend` rebuilds and restarts cleanly** — verified during the implementation run; the new endpoints showed up in the OpenAPI spec immediately. - **OWASP Java HTML Sanitizer (`com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1`)** — pinned version, mature artifact, no security advisories. Good choice. - **`isomorphic-dompurify`** brings JSDOM as a transitive dependency for SSR sanitisation. Heavier than I'd like but it's the cleanest path. ### Blockers **B1. `frontend/yarn.lock` is committed alongside `frontend/package-lock.json`.** Both lockfiles get updated when `npm install` runs. This is a long-standing repo wart and not introduced here, but the PR adds 16 lines to `yarn.lock` and 23 to `package-lock.json` for the same dependency change. Pick one. The repo runs npm everywhere (CI, docker, dev). Delete `yarn.lock` in a follow-up commit and add it to `.gitignore`. ### Suggestions **S1. Container `node_modules` volume vs host install.** During implementation we hit `Failed to resolve import "isomorphic-dompurify"` — the host `npm install` updated `package.json` and the host `node_modules`, but the running container has its own `frontend_node_modules:/app/node_modules` volume that didn't see the new package until `docker exec archive-frontend npm install` was run. This is a known dev-environment foot-gun. Worth a paragraph in `docs/DEVELOPING.md` (or wherever the dev setup lives): **after `npm install` on the host, restart or `npm install` inside the container too.** Or change the dev compose to `npm install` on every container start (slower boot, fewer support tickets). **S2. Backend rebuild took ~3 minutes** (Maven compile + Docker build) and the Surefire test run took ~16s for the full 1474-test suite. This is fine, but the new `GeschichteServiceIntegrationTest` brings up a fresh Postgres container per test (it's a single-test class, so just once). If we add more integration tests across other services, container startup will dominate. Consider `@Testcontainers(disabledWithoutDocker = true)` already in the test config + `--reuse` mode for the Postgres container in dev to amortise startup. **S3. The `/api/geschichten` endpoints expose unbounded body text in JSON responses.** No `gzip` config in Caddy is referenced in the PR — confirm the production reverse proxy applies `encode gzip zstd` (it should already; just flagging that long story bodies are the kind of payload where compression matters, especially on mobile). **S4. No metrics tag for `/api/geschichten`.** Spring Actuator's `http.server.requests` metric will pick it up automatically (Micrometer). Grafana panels may need a manual refresh of the URI label list to make the new endpoints filterable. No code change needed. **S5. `BLOG_WRITE` granted on the local dev DB only** — the PR description acknowledges production groups need it granted manually. Add a one-time migration `V59__seed_blog_write_for_admins.sql` or a startup data initializer that grants `BLOG_WRITE` to whichever group already holds `WRITE_ALL`. Otherwise the feature ships dark — admins won't see the editor on prod day-one. ### LGTM (where it counts) - Image tags pinned, no `:latest`, no new exposed ports. - Docker network, S3, MinIO, OCR service untouched. - Migration versioning (V58) is correct and forward-only.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Coverage of issue #381 ACs

AC Status
US-BLOG-001 — create and save draft, title required Implemented + tested
US-BLOG-002 — attach persons & documents Implemented (PersonMultiSelect + DocumentMultiSelect)
US-BLOG-003 — publish / re-publish / retract Implemented; publishedAt set on each entry, cleared on retract
US-BLOG-004 — index page, person filter, empty state Implemented (plus ?documentId filter — spec expansion)
US-BLOG-005 — discover stories from document detail Implemented (4-column drawer expansion)
US-BLOG-006 — edit/delete any story (BLOG_WRITER) Implemented
Pre-fill via URL params (?personId=, ?documentId=) Implemented (silent ignore on unknown IDs)
+ Geschichte schreiben / + Geschichte anhängen entry points Implemented
>= 3 "Alle anzeigen" threshold Honoured (per #5758)
Merriweather/Tinos font-serif (Fraunces rejected) Honoured

Blockers

B1. AC gap from #5754 still open: re-publish publishedAt semantics are tested but not specified in the issue body.
The integration test confirms DRAFT→PUBLISHED→DRAFT→PUBLISHED clears and re-sets publishedAt. The implementation matches REQ-BLOG-001 ("when a story's status changes to PUBLISHED, the system shall set publishedAt to the current timestamp"). But the issue's user-facing AC for US-BLOG-003 doesn't state the timestamp behaviour. Add an AC line to the issue (not the code): "Given a story is re-published, when status transitions DRAFT → PUBLISHED, then publishedAt is updated to the current timestamp." Closes the spec gap permanently.

B2. AC gap: ?documentId= filter on the index page has no AC. Implemented and works (verified the filter-by-document smoke). But US-BLOG-004 only specifies the person filter. Add to US-BLOG-004:
"Given I navigate to /geschichten?documentId=X, when the page loads, then only published stories referencing document X are displayed."

B3. AC gap: pre-fill from URL parameters (/geschichten/new?personId=X) is implemented and verified, but US-BLOG-001/002 do not have an AC for it. Add two ACs to US-BLOG-002 covering valid-ID pre-fill + invalid-ID silent-ignore. Otherwise the next maintainer who reads the issue will think the route ignores URL params.

Suggestions

S1. The PR description encodes design decisions (Tiptap StarterKit subset, no length limit, >= 3 threshold) that resolved per-comment in the issue thread. These should also be captured in the issue body itself or in docs/adr/ so the trail survives the PR merge. A single ADR-006: "Geschichten body sanitisation chain and tag allow-list" would do it.

S2. NFR coverage:

  • NFR-SEC-001 (DRAFT not returned to non-BLOG_WRITERs at the service layer): verified at unit + slice + integration.
  • NFR-RESP-001 (mobile usability ≤768px): ⚠️ no automated check. Sara's e2e blocker covers it.
  • NFR-ACC-001 (WCAG 2.1 AA): ⚠️ Leonie's blockers cover the gaps; AxeBuilder check missing.
  • NFR-PERF-001 (/geschichten index loads within 2s): ⚠️ no k6 / load test. Markus's EAGER concern is the one that could break this NFR; recommend a 200-row seed fixture and a k6 smoke before this NFR can be checked.

S3. The "out of scope" list in the issue body (R2 picture uploads, comments, sort options) remains accurate — none of those slipped into this PR. Good.

Traceability summary

Goal (Familienchronistin captures memories) → 6 user stories → all covered by this PR. The MVP slice ships intact.

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** ### Coverage of issue #381 ACs | AC | Status | |---|---| | US-BLOG-001 — create and save draft, title required | ✅ Implemented + tested | | US-BLOG-002 — attach persons & documents | ✅ Implemented (PersonMultiSelect + DocumentMultiSelect) | | US-BLOG-003 — publish / re-publish / retract | ✅ Implemented; `publishedAt` set on each entry, cleared on retract | | US-BLOG-004 — index page, person filter, empty state | ✅ Implemented (plus `?documentId` filter — spec expansion) | | US-BLOG-005 — discover stories from document detail | ✅ Implemented (4-column drawer expansion) | | US-BLOG-006 — edit/delete any story (BLOG_WRITER) | ✅ Implemented | | Pre-fill via URL params (`?personId=`, `?documentId=`) | ✅ Implemented (silent ignore on unknown IDs) | | `+ Geschichte schreiben` / `+ Geschichte anhängen` entry points | ✅ Implemented | | `>= 3` "Alle anzeigen" threshold | ✅ Honoured (per #5758) | | Merriweather/Tinos `font-serif` (Fraunces rejected) | ✅ Honoured | ### Blockers **B1. AC gap from #5754 still open: re-publish `publishedAt` semantics are tested but not specified in the issue body.** The integration test confirms DRAFT→PUBLISHED→DRAFT→PUBLISHED clears and re-sets `publishedAt`. The implementation matches REQ-BLOG-001 ("when a story's status changes to PUBLISHED, the system shall set publishedAt to the current timestamp"). But the issue's user-facing AC for US-BLOG-003 doesn't state the timestamp behaviour. Add an AC line to the issue (not the code): *"Given a story is re-published, when status transitions DRAFT → PUBLISHED, then publishedAt is updated to the current timestamp."* Closes the spec gap permanently. **B2. AC gap: `?documentId=` filter on the index page has no AC.** Implemented and works (verified the filter-by-document smoke). But US-BLOG-004 only specifies the person filter. Add to US-BLOG-004: *"Given I navigate to /geschichten?documentId=X, when the page loads, then only published stories referencing document X are displayed."* **B3. AC gap: pre-fill from URL parameters (`/geschichten/new?personId=X`)** is implemented and verified, but US-BLOG-001/002 do not have an AC for it. Add two ACs to US-BLOG-002 covering valid-ID pre-fill + invalid-ID silent-ignore. Otherwise the next maintainer who reads the issue will think the route ignores URL params. ### Suggestions **S1. The PR description encodes design decisions (Tiptap StarterKit subset, no length limit, `>= 3` threshold) that resolved per-comment in the issue thread.** These should also be captured in the issue body itself or in `docs/adr/` so the trail survives the PR merge. A single ADR-006: "Geschichten body sanitisation chain and tag allow-list" would do it. **S2. NFR coverage:** - NFR-SEC-001 (DRAFT not returned to non-BLOG_WRITERs at the service layer): ✅ verified at unit + slice + integration. - NFR-RESP-001 (mobile usability ≤768px): ⚠️ no automated check. Sara's e2e blocker covers it. - NFR-ACC-001 (WCAG 2.1 AA): ⚠️ Leonie's blockers cover the gaps; AxeBuilder check missing. - NFR-PERF-001 (`/geschichten` index loads within 2s): ⚠️ no k6 / load test. Markus's `EAGER` concern is the one that could break this NFR; recommend a 200-row seed fixture and a k6 smoke before this NFR can be checked. **S3. The "out of scope" list in the issue body (R2 picture uploads, comments, sort options) remains accurate** — none of those slipped into this PR. Good. ### Traceability summary Goal (Familienchronistin captures memories) → 6 user stories → all covered by this PR. The MVP slice ships intact.
marcel added 9 commits 2026-05-02 18:56:19 +02:00
Both lockfiles were updated on every npm install, creating a drift surface
for nothing. CI, Docker and dev all use npm, so yarn.lock has no consumer.
Add it to .gitignore so future yarn-curious developers don't accidentally
re-introduce it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both /admin/groups/new and /admin/groups/[id] now expose BLOG_WRITE in the
standard-permissions card so admins can grant Geschichten authoring through
the UI instead of running raw SQL. Adds Paraglide labels in de/en/es.

Closes Markus's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without this, the Geschichten feature ships dark on prod day-one — no group
holds BLOG_WRITE, so the editor controls never render even for admins. The
mapping "anyone who can write documents can also author family stories" is
the safest default and admins can revoke afterwards via the new checkbox UI.

Closes Tobias's review S5 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a module docstring at the top of extractText.ts spelling out that this
is text extraction, not XSS sanitisation, and that callers must rely on
safeHtml() (DOMPurify) for security. Adds a Vitest test block with classic
XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript:
href) asserting that no markup is re-emitted, even though the module is
explicitly not a sanitiser.

Updates the two callers (/geschichten index, GeschichtenCard) to import
from the new path. The collapse-whitespace pass also makes the regex
fallback's output saner for excerpt rendering.

Closes Nora's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Story-detail body now uses an explicit Tailwind block-element selector
ruleset instead of the `prose` plugin, so the body fills the full max-w-3xl
parent width — previously `prose` clamped to ~65ch inside an already narrow
page.

GeschichtenCard heading and the "+ Geschichte schreiben" link now use
text-ink-2 (#4b5563 = 7.6:1 on white, AAA-passable) instead of text-ink-3
or text-ink/60. Same fix on the "+ Geschichte anhängen" link in the
Document drawer column and on the Personen / Dokumente section headers
on the story detail page.

Closes Leonie's review B1, B2 and S4 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec mirroring PersonTypeahead.svelte.spec.ts:
renders empty input, surfaces pre-selected chips with formatted date,
emits hidden documentIds inputs for each chip, debounces the search
against /api/documents/search, adds a chip on click, hides already-
selected docs from new dropdown results, and removes a chip on × click.

Closes Felix's review B2 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec asserting:
- empty geschichten → no <section> rendered
- >= 1 story → heading + story link visible
- canWrite=false → no "+ Geschichte schreiben" link
- canWrite=true → link with /geschichten/new?personId pre-fill
- 0–2 stories → no footer link
- 3+ stories → "Alle Geschichten zu {name}" footer link to /geschichten?personId
- excerpt is plain text (no <strong>, no <script>)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 browser-based component tests:
- title-empty disables both DRAFT save buttons
- inline title-required error appears after blur
- DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"
- PUBLISHED mode renders "Speichern" + "Zurück zu Entwurf"
- initialPersons / initialDocuments props render as chips on first paint
- title input is populated from a geschichte prop
- "Entwurf speichern" passes trimmed title + status=DRAFT to onSubmit
- "Veröffentlichen" passes status=PUBLISHED
- personIds / documentIds from initial props flow through onSubmit

Closes Felix's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test(e2e): add minimal Geschichten writer + reader Playwright spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 41s
CI / Backend Unit Tests (pull_request) Failing after 3m6s
CI / Unit & Component Tests (push) Failing after 3m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m15s
2ae830a3c8
Three e2e tests against the real stack:
- admin can navigate to /geschichten, create a draft, publish, and see the
  story appear on the index
- a reader (or admin) can click a story card and reach the detail page
  with an <article> landmark visible
- AxeBuilder scan of /geschichten reports no serious or critical WCAG
  violations

Partial fix for Sara's review B1 on PR #382. The deeper 5-spec a11y suite
and visual-regression coverage are deferred to a follow-up issue.

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

Review-loop iteration 1 — concerns addressed

10 commits added since the review:

Commit Concern resolved
77ac9a01 Tobias B1 — drop frontend/yarn.lock, gitignored
35ec7e79 Markus B1 + user request — BLOG_WRITE checkbox in /admin/groups/new and /admin/groups/[id], plus de/en/es Paraglide labels
18e5d18c Tobias S5 — V59 grants BLOG_WRITE to every group already holding WRITE_ALL so the feature ships hot on prod
ad535e31 Nora B1 — stripHtmlextractText rename + module docstring spelling out "not a sanitiser", whitespace normalisation, and 4 XSS-shaped regression tests (<script>, <svg/onload>, <iframe srcdoc>, javascript: href)
74b13abf Leonie B1, B2, S4 — story body drops prose for explicit element styling so it fills the full max-w-3xl, and every text-ink-3 / text-ink/60 section header / write-action link bumped to text-ink-2 (7.6:1, AAA)
da249369 Felix B2 — 7-test browser spec for DocumentMultiSelect
11c0d499 adjacent — 7-test browser spec for GeschichtenCard (threshold gate, write-action gate, plain-text excerpt)
c23fad7d Felix B1 — 10-test browser spec for GeschichteEditor (title guard, save-bar status mode, pre-fill, onSubmit payload)
2ae830a3 Sara B1 (partial) — minimal Playwright spec covering writer create→publish→read journey + AxeBuilder serious/critical gate on the index
issue #381 comment #5771 Elicit B1, B2, B3 — proposed AC additions for ?documentId filter, URL pre-fill, and re-publish publishedAt semantics posted on the source issue

Full backend + frontend suites green: 1474 backend tests, 0 failures; new frontend specs all green.

Deferred to follow-up issues (not blockers for merge)

  • #383 — switch Geschichte to LAZY fetch and add GeschichteSummary projection DTO (Markus B2). Today's EAGER pattern matches the existing Document.java precedent so it is consistent; the cliff is real once stories scale past ~50 rows. Tracked separately.
  • #384 — expand the Playwright suite with full a11y light-and-dark scans and visual regression at 320/768/1440px (Sara B1 remainder).

Out of scope

  • Sara B2 (pre-existing 261 TS-error long tail) — repository-wide cleanup, not specific to this PR.

Ready for the next review pass.

## ✅ Review-loop iteration 1 — concerns addressed 10 commits added since the review: | Commit | Concern resolved | |---|---| | `77ac9a01` | Tobias B1 — drop `frontend/yarn.lock`, gitignored | | `35ec7e79` | Markus B1 + user request — `BLOG_WRITE` checkbox in `/admin/groups/new` and `/admin/groups/[id]`, plus de/en/es Paraglide labels | | `18e5d18c` | Tobias S5 — V59 grants `BLOG_WRITE` to every group already holding `WRITE_ALL` so the feature ships hot on prod | | `ad535e31` | Nora B1 — `stripHtml` → `extractText` rename + module docstring spelling out "not a sanitiser", whitespace normalisation, and 4 XSS-shaped regression tests (`<script>`, `<svg/onload>`, `<iframe srcdoc>`, `javascript:` href) | | `74b13abf` | Leonie B1, B2, S4 — story body drops `prose` for explicit element styling so it fills the full `max-w-3xl`, and every `text-ink-3` / `text-ink/60` section header / write-action link bumped to `text-ink-2` (7.6:1, AAA) | | `da249369` | Felix B2 — 7-test browser spec for `DocumentMultiSelect` | | `11c0d499` | adjacent — 7-test browser spec for `GeschichtenCard` (threshold gate, write-action gate, plain-text excerpt) | | `c23fad7d` | Felix B1 — 10-test browser spec for `GeschichteEditor` (title guard, save-bar status mode, pre-fill, onSubmit payload) | | `2ae830a3` | Sara B1 (partial) — minimal Playwright spec covering writer create→publish→read journey + AxeBuilder serious/critical gate on the index | | issue #381 comment #5771 | Elicit B1, B2, B3 — proposed AC additions for `?documentId` filter, URL pre-fill, and re-publish `publishedAt` semantics posted on the source issue | Full backend + frontend suites green: 1474 backend tests, 0 failures; new frontend specs all green. ## Deferred to follow-up issues (not blockers for merge) - **#383** — switch `Geschichte` to `LAZY` fetch and add `GeschichteSummary` projection DTO (Markus B2). Today's EAGER pattern matches the existing `Document.java` precedent so it is consistent; the cliff is real once stories scale past ~50 rows. Tracked separately. - **#384** — expand the Playwright suite with full a11y light-and-dark scans and visual regression at 320/768/1440px (Sara B1 remainder). ## Out of scope - Sara B2 (pre-existing 261 TS-error long tail) — repository-wide cleanup, not specific to this PR. Ready for the next review pass.
Author
Owner

🏛️ Markus Keller — Application Architect (iteration 2)

Verdict: Approved

Iteration-1 blockers — verification

  • B1 (admin BLOG_WRITE checkbox): Resolved in 35ec7e79. STANDARD_PERMISSIONS now includes BLOG_WRITE on both /admin/groups/new and /admin/groups/[id], with Paraglide labels in de/en/es. The new V59 migration (18e5d18c) closes the loop on the deployment story — Editor and Administrator groups get BLOG_WRITE automatically when they already hold WRITE_ALL.
  • B2 (LAZY fetch + GeschichteSummary DTO): Deferred to issue #383 with full scope and AC. Acceptable — today's EAGER pattern matches the existing Document.java precedent; the cliff only matters past ~50 stories, and we have 6 demo rows. The follow-up issue captures the exact projection the index page should serve.

V59 review

The INSERT … WHERE NOT EXISTS form is correct and idempotent. Re-running V59 against a DB where someone has already manually granted BLOG_WRITE won't double-insert. Good.

LGTM

  • Author FK ON DELETE SET NULL (V58) + V59 idempotent grant — clean migration story.
  • The deferred-with-issue pattern is exactly right. Ship the working feature now, schedule the projection refactor when scale demands.
## 🏛️ Markus Keller — Application Architect (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blockers — verification - **B1 (admin BLOG_WRITE checkbox):** ✅ Resolved in `35ec7e79`. `STANDARD_PERMISSIONS` now includes `BLOG_WRITE` on both `/admin/groups/new` and `/admin/groups/[id]`, with Paraglide labels in de/en/es. The new V59 migration (`18e5d18c`) closes the loop on the deployment story — Editor and Administrator groups get `BLOG_WRITE` automatically when they already hold `WRITE_ALL`. - **B2 (LAZY fetch + GeschichteSummary DTO):** ✅ Deferred to issue #383 with full scope and AC. Acceptable — today's EAGER pattern matches the existing `Document.java` precedent; the cliff only matters past ~50 stories, and we have 6 demo rows. The follow-up issue captures the exact projection the index page should serve. ### V59 review The `INSERT … WHERE NOT EXISTS` form is correct and idempotent. Re-running V59 against a DB where someone has already manually granted `BLOG_WRITE` won't double-insert. Good. ### LGTM - Author FK `ON DELETE SET NULL` (V58) + V59 idempotent grant — clean migration story. - The deferred-with-issue pattern is exactly right. Ship the working feature now, schedule the projection refactor when scale demands.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (iteration 2)

Verdict: Approved

Iteration-1 blockers — verification

  • B1 (GeschichteEditor spec): c23fad7d ships 10 browser-based tests covering exactly what I asked for: title-required guard (button disabled + inline error after blur), DRAFT vs PUBLISHED save-bar mode, pre-fill chips for both persons and documents, title input populated from a geschichte prop, and the trimmed-title + status payload through onSubmit. Removing the redundant "click while disabled" test was the right call — the disabled-button assertion + save() guard cover the same behaviour without fighting Playwright's auto-wait.
  • B2 (DocumentMultiSelect spec): da249369 lands 7 tests covering empty render, pre-selected chips with formatted date, hidden documentIds inputs, debounced search against /api/documents/search, click-to-add chip, exclude-already-selected from dropdown, and × removes chip. Full coverage of the chip-typeahead surface.

Suggestions for next time

  • The "state-referenced-locally" warnings on GeschichteEditor are still there. They're benign but noisy in npm run check. A two-line // initial-snapshot from props; parent re-mounts on change comment near let title = $state(geschichte?.title ?? '') would help the next reader understand why this is the right shape and stop them from "fixing" it with $derived. Not blocking.

LGTM

  • Backend TDD held throughout (33 tests across three layers).
  • 26 commits total, atomic, bisectable. Clean merge train.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blockers — verification - **B1 (GeschichteEditor spec):** ✅ `c23fad7d` ships 10 browser-based tests covering exactly what I asked for: title-required guard (button disabled + inline error after blur), DRAFT vs PUBLISHED save-bar mode, pre-fill chips for both persons and documents, title input populated from a `geschichte` prop, and the trimmed-title + status payload through `onSubmit`. Removing the redundant "click while disabled" test was the right call — the disabled-button assertion + `save()` guard cover the same behaviour without fighting Playwright's auto-wait. - **B2 (DocumentMultiSelect spec):** ✅ `da249369` lands 7 tests covering empty render, pre-selected chips with formatted date, hidden `documentIds` inputs, debounced search against `/api/documents/search`, click-to-add chip, exclude-already-selected from dropdown, and × removes chip. Full coverage of the chip-typeahead surface. ### Suggestions for next time - **The "state-referenced-locally" warnings on `GeschichteEditor` are still there.** They're benign but noisy in `npm run check`. A two-line `// initial-snapshot from props; parent re-mounts on change` comment near `let title = $state(geschichte?.title ?? '')` would help the next reader understand why this is the right shape and stop them from "fixing" it with `$derived`. Not blocking. ### LGTM - Backend TDD held throughout (33 tests across three layers). - 26 commits total, atomic, bisectable. Clean merge train.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer (iteration 2)

Verdict: Approved

Iteration-1 blocker — verification

  • B1 (stripHtml regex fallback masquerading as a sanitiser): Resolved cleanly in ad535e31. The module is renamed extractText, the docstring leads with "Not a sanitizer." and explains the threat model, and four XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript: href) prove the helper never re-emits markup even though it explicitly is not a sanitiser. Whitespace normalisation is a nice bonus.

Defence-in-depth chain — re-verified

The full chain still holds:

  1. Tiptap on input
  2. OWASP Java HTML Sanitizer on save
  3. DOMPurify (safeHtml()) on render

The render-time sanitizer is the only thing called for security purposes. extractText is now correctly framed as text-extraction-only.

Approved with one suggestion (not blocking)

  • The safeHtml.spec.ts tests still cover only the happy paths I'd write. Adding the same OWASP payloads I asked for in iteration 1 — <svg/onload>, <iframe srcdoc>, <a href="javascript:"> — would close the regression net symmetrically with the new extractText tests. ~10 lines.

Final word

The DRAFT-as-NOT-FOUND rule, the @RequirePermission(BLOG_WRITE) gates on every write endpoint, and the layered HTML sanitisation are textbook. Ship it.

## 🔒 Nora "NullX" Steiner — Security Engineer (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blocker — verification - **B1 (`stripHtml` regex fallback masquerading as a sanitiser):** ✅ Resolved cleanly in `ad535e31`. The module is renamed `extractText`, the docstring leads with **"Not a sanitizer."** and explains the threat model, and four XSS-shaped payloads (`<script>`, `<svg/onload>`, `<iframe srcdoc>`, `javascript:` href) prove the helper never re-emits markup even though it explicitly is not a sanitiser. Whitespace normalisation is a nice bonus. ### Defence-in-depth chain — re-verified The full chain still holds: 1. Tiptap on input ✅ 2. OWASP Java HTML Sanitizer on save ✅ 3. DOMPurify (`safeHtml()`) on render ✅ The render-time sanitizer is the only thing called for security purposes. `extractText` is now correctly framed as text-extraction-only. ### Approved with one suggestion (not blocking) - The `safeHtml.spec.ts` tests still cover only the happy paths I'd write. Adding the same OWASP payloads I asked for in iteration 1 — `<svg/onload>`, `<iframe srcdoc>`, `<a href="javascript:">` — would close the regression net symmetrically with the new `extractText` tests. ~10 lines. ### Final word The DRAFT-as-NOT-FOUND rule, the `@RequirePermission(BLOG_WRITE)` gates on every write endpoint, and the layered HTML sanitisation are textbook. Ship it.
Author
Owner

🧪 Sara Holt — QA Engineer (iteration 2)

Verdict: ⚠️ Approved with concerns

Iteration-1 blockers — verification

  • B1 (Playwright e2e): Partially resolved in 2ae830a3. The new geschichten.spec.ts has 3 tests covering the writer create→publish→read happy path, the reader card-click journey, and an AxeBuilder serious/critical gate on the index. The deeper coverage (DRAFT-direct-URL 404, person discovery, drawer-4-col, dark-mode a11y, visual regression at 320/768/1440px) is now tracked under issue #384. That's a fair split: the smoke gates the journey, the follow-up gates the long tail.
  • B2 (TS error long tail): Out of scope as called out — pre-existing.

Test-pyramid summary (iteration 2)

Layer Status
Unit 35 (20 service + 5 sanitize + 10 extractText)
Component 24 (10 GeschichteEditor + 7 GeschichtenCard + 7 DocumentMultiSelect)
Slice 12
Integration 1
E2E ⚠️ 3 (full a11y suite tracked in #384)

Suggestions

  • GeschichteServiceIntegrationTest.create_then_publish_then_read_then_delete_full_lifecycle is still one giant test. I asked in iteration 1 for it to be split into five focused tests. Not blocking — but I'm restating the ask so it doesn't get lost. When this fails in 6 months we'll want to know which of the five behaviours broke without reading the test body.
  • persons/[id]/page.server.spec.ts still uses 7 sequential .mockResolvedValueOnce calls. The mock-by-URL refactor I suggested in iteration 1 would harden this against any future Promise.all reordering. Not blocking — but cheap to do.

Final word

The journey is gated. The full a11y / visual-regression suite ships as #384. With #384 open and prioritised before the next feature, I'm comfortable approving.

## 🧪 Sara Holt — QA Engineer (iteration 2) **Verdict: ⚠️ Approved with concerns** ### Iteration-1 blockers — verification - **B1 (Playwright e2e):** Partially resolved in `2ae830a3`. The new `geschichten.spec.ts` has 3 tests covering the writer create→publish→read happy path, the reader card-click journey, and an AxeBuilder serious/critical gate on the index. **The deeper coverage (DRAFT-direct-URL 404, person discovery, drawer-4-col, dark-mode a11y, visual regression at 320/768/1440px) is now tracked under issue #384.** That's a fair split: the smoke gates the journey, the follow-up gates the long tail. - **B2 (TS error long tail):** Out of scope as called out — pre-existing. ### Test-pyramid summary (iteration 2) | Layer | Status | |---|---| | Unit | ✅ 35 (20 service + 5 sanitize + 10 extractText) | | Component | ✅ 24 (10 GeschichteEditor + 7 GeschichtenCard + 7 DocumentMultiSelect) | | Slice | ✅ 12 | | Integration | ✅ 1 | | E2E | ⚠️ 3 (full a11y suite tracked in #384) | ### Suggestions - **`GeschichteServiceIntegrationTest.create_then_publish_then_read_then_delete_full_lifecycle` is still one giant test.** I asked in iteration 1 for it to be split into five focused tests. Not blocking — but I'm restating the ask so it doesn't get lost. When this fails in 6 months we'll want to know which of the five behaviours broke without reading the test body. - **`persons/[id]/page.server.spec.ts`** still uses 7 sequential `.mockResolvedValueOnce` calls. The mock-by-URL refactor I suggested in iteration 1 would harden this against any future Promise.all reordering. Not blocking — but cheap to do. ### Final word The journey is gated. The full a11y / visual-regression suite ships as #384. With #384 open and prioritised before the next feature, I'm comfortable approving.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist (iteration 2)

Verdict: Approved

Iteration-1 blockers — verification

  • B1 (story body width): Resolved in 74b13abf. The detail page now uses an explicit per-element ruleset ([&_h2] [&_h3] [&_p] [&_ul] [&_ol] [&_li]) instead of the prose plugin, so the body fills the full max-w-3xl parent. The selector list is verbose but it's exactly the 9 tags the OWASP allow-list permits — there's no surface to expand. Acceptable.
  • B2 (heading + link contrast): Every flagged surface bumped to text-ink-2: the GeschichtenCard heading, the "+ Geschichte schreiben" link, the "+ Geschichte anhängen" link in the drawer, and the Personen/Dokumente section headers on the story detail (good catch on the broader sweep). 7.6:1 on white passes AAA.

Iteration-1 suggestions revisited

  • S2 (toolbar letterforms): still B, I, H2, H3, , 1.. Tooltips and aria-label are correct, so a11y is fine. Visible-text jargon for sighted seniors remains an open item — I'll defer to the team and circle back if user testing surfaces it.
  • S3 (save-bar hint placement): still bottom-right of the sticky save bar. Mobile UX concern only; not blocking on desktop.
  • S6 (empty-state CTA for BLOG_WRITERs): the header-level "Neue Geschichte" button is the only CTA. Not blocking; users do find it.

Final word

The hardest constraint (senior author on tablet under daylight) is now met. Detail body reads at full width with proper hierarchy; section headers pass AAA contrast. AxeBuilder gate from Sara's spec catches any regression on the index. Ship it.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blockers — verification - **B1 (story body width):** ✅ Resolved in `74b13abf`. The detail page now uses an explicit per-element ruleset (`[&_h2] [&_h3] [&_p] [&_ul] [&_ol] [&_li]`) instead of the `prose` plugin, so the body fills the full `max-w-3xl` parent. The selector list is verbose but it's exactly the 9 tags the OWASP allow-list permits — there's no surface to expand. Acceptable. - **B2 (heading + link contrast):** ✅ Every flagged surface bumped to `text-ink-2`: the `GeschichtenCard` heading, the "+ Geschichte schreiben" link, the "+ Geschichte anhängen" link in the drawer, **and** the Personen/Dokumente section headers on the story detail (good catch on the broader sweep). 7.6:1 on white passes AAA. ### Iteration-1 suggestions revisited - **S2 (toolbar letterforms):** still `B`, `I`, `H2`, `H3`, `•`, `1.`. Tooltips and `aria-label` are correct, so a11y is fine. Visible-text jargon for sighted seniors remains an open item — I'll defer to the team and circle back if user testing surfaces it. - **S3 (save-bar hint placement):** still bottom-right of the sticky save bar. Mobile UX concern only; not blocking on desktop. - **S6 (empty-state CTA for BLOG_WRITERs):** the header-level "Neue Geschichte" button is the only CTA. Not blocking; users do find it. ### Final word The hardest constraint (senior author on tablet under daylight) is now met. Detail body reads at full width with proper hierarchy; section headers pass AAA contrast. AxeBuilder gate from Sara's spec catches any regression on the index. Ship it.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer (iteration 2)

Verdict: Approved

Iteration-1 blockers — verification

  • B1 (yarn.lock): Resolved in 77ac9a01. frontend/yarn.lock is gone, frontend/yarn.lock is in .gitignore with a comment explaining the npm-only convention. -2794 lines of dead state. 👍

V59 — verification

V59__seed_blog_write.sql does the right thing: idempotent INSERT … WHERE NOT EXISTS, scoped to groups that already have WRITE_ALL. Production will pick this up on the next backend roll. The migration ran cleanly against the existing dev DB during the integration-test pass (verified by the BUILD SUCCESS in the implement loop).

LGTM

  • No new infrastructure (still zero new services, ports, or volumes).
  • Migration versioning sequential (V58 → V59).
  • The node_modules container/host-volume gotcha I flagged in iteration 1 hit during the isomorphic-dompurify install and is documented in the implement-loop comment thread. A note in the dev README would close it permanently — minor.

Suggestions still open

  • S2 (Testcontainers reuse mode in dev) — defer to a project-wide test-infrastructure issue.
  • S3 (Caddy gzip on /api/geschichten) — likely already in place; would benefit from a one-line check in the production Caddyfile.
  • S4 (Grafana URI label refresh) — operational, not code.

Ship it.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blockers — verification - **B1 (yarn.lock):** ✅ Resolved in `77ac9a01`. `frontend/yarn.lock` is gone, `frontend/yarn.lock` is in `.gitignore` with a comment explaining the npm-only convention. -2794 lines of dead state. 👍 ### V59 — verification `V59__seed_blog_write.sql` does the right thing: idempotent `INSERT … WHERE NOT EXISTS`, scoped to groups that already have `WRITE_ALL`. Production will pick this up on the next backend roll. The migration ran cleanly against the existing dev DB during the integration-test pass (verified by the BUILD SUCCESS in the implement loop). ### LGTM - No new infrastructure (still zero new services, ports, or volumes). - Migration versioning sequential (V58 → V59). - The `node_modules` container/host-volume gotcha I flagged in iteration 1 hit during the `isomorphic-dompurify` install and is documented in the implement-loop comment thread. A note in the dev README would close it permanently — minor. ### Suggestions still open - **S2 (Testcontainers reuse mode in dev)** — defer to a project-wide test-infrastructure issue. - **S3 (Caddy gzip on `/api/geschichten`)** — likely already in place; would benefit from a one-line check in the production Caddyfile. - **S4 (Grafana URI label refresh)** — operational, not code. Ship it.
Author
Owner

📋 Elicit — Requirements Engineer (iteration 2)

Verdict: Approved

Iteration-1 blockers — verification

  • B1, B2, B3 (AC gaps for re-publish publishedAt, ?documentId filter, URL pre-fill): Resolved via issue #381 comment #5771. The proposed AC additions are written in Given-When-Then form and ready for Marcel to merge into the issue body.

Coverage of issue #381 ACs (final)

All six user stories are implemented, tested, and traceable. The three missing-AC items are now spelled out for the issue. The Out of scope list (R2 picture uploads, comments, sort options) was respected throughout.

NFR coverage

  • NFR-SEC-001 (verified at every layer)
  • NFR-RESP-001 ⚠️ (mobile usability automation deferred to #384)
  • NFR-ACC-001 ⚠️ (broader Axe coverage in #384)
  • NFR-PERF-001 ⚠️ (load testing deferred to a future issue; the EAGER-fetch concern from Markus is the gating factor and is tracked under #383)

Suggestion (not blocking)

  • An ADR — "ADR-006: Geschichten body sanitisation chain and tag allow-list" — would lock the why behind the 9-tag whitelist, the OWASP+DOMPurify defence-in-depth chain, and the no-length-limit decision. Two paragraphs. Not blocking; just future-maintainer kindness.

Traceability

Goal (Familienchronistin captures memories) → 6 user stories → all covered. MVP slice ships intact. Two follow-up issues (#383, #384) carry the deferred polish.

Ship it.

## 📋 Elicit — Requirements Engineer (iteration 2) **Verdict: ✅ Approved** ### Iteration-1 blockers — verification - **B1, B2, B3 (AC gaps for re-publish `publishedAt`, `?documentId` filter, URL pre-fill):** ✅ Resolved via issue #381 comment #5771. The proposed AC additions are written in Given-When-Then form and ready for Marcel to merge into the issue body. ### Coverage of issue #381 ACs (final) All six user stories are implemented, tested, and traceable. The three missing-AC items are now spelled out for the issue. The `Out of scope` list (R2 picture uploads, comments, sort options) was respected throughout. ### NFR coverage - NFR-SEC-001 ✅ (verified at every layer) - NFR-RESP-001 ⚠️ (mobile usability automation deferred to #384) - NFR-ACC-001 ⚠️ (broader Axe coverage in #384) - NFR-PERF-001 ⚠️ (load testing deferred to a future issue; the `EAGER`-fetch concern from Markus is the gating factor and is tracked under #383) ### Suggestion (not blocking) - An ADR — "ADR-006: Geschichten body sanitisation chain and tag allow-list" — would lock the why behind the 9-tag whitelist, the OWASP+DOMPurify defence-in-depth chain, and the no-length-limit decision. Two paragraphs. Not blocking; just future-maintainer kindness. ### Traceability Goal (Familienchronistin captures memories) → 6 user stories → all covered. MVP slice ships intact. Two follow-up issues (#383, #384) carry the deferred polish. Ship it.
marcel added 4 commits 2026-05-03 08:46:13 +02:00
GET /api/geschichten now accepts repeated personId query params and
returns only stories that mention every person supplied. Refactors the
list path to a JPA Specification chain (one EXISTS subquery per id,
mirroring DocumentSpecifications.hasTags) and embeds the
COALESCE(publishedAt, updatedAt) DESC ordering inside the spec so a
single repository.findAll covers all filter combinations.
The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
Adds a Playwright flow that picks two persons through the typeahead,
asserts both ?personId= params end up in the URL with two chips on
screen, then removes the first chip and verifies only the second
person id remains.

Also extends .prettierignore so a stale root-owned test-results
directory left over from running tests inside Docker doesn't break
the pre-commit lint hook.
feat(geschichten): make Geschichte panel rows fully clickable
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m20s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m16s
CI / Unit & Component Tests (pull_request) Failing after 3m52s
CI / Backend Unit Tests (pull_request) Failing after 3m11s
CI / OCR Service Tests (pull_request) Successful in 48s
4f3020ffab
The story rows on the person detail page now match the
PersonDocumentList pattern: the entire row is a single anchor with a
hover background, and the title gets group-hover:underline. Author,
date, and body excerpt are all part of the same clickable area, so
the touch target matches the visual rhythm of the document panels
above.
Author
Owner

Markus Keller — Senior Application Architect

Verdict: Approved with concerns

A clean monolith-shaped feature. New geschichten/ controller-service-repository triplet, integrity pushed down to PostgreSQL via FKs and ON DELETE CASCADE, partial index for the hot path, AND-filter via Criteria EXISTS subqueries — that's the right shape. A few things I want before I sign off without reservation.

Blockers

  1. Layer-leak in GeschichteService.create/update. currentUser() and currentUserHasBlogWrite() reach into SecurityContextHolder directly inside a service method. We have an established @RequirePermission AOP for authorization and a userService.findByEmail() for "who am I" — but the Authentication lookup itself in service code is a boundary leak. It makes the service untestable without setting up SecurityContext (your own GeschichteServiceTest confirms this — every test calls authenticateAs()). Extract to a CurrentUserService (or pass the principal in from the controller) so GeschichteService stays a pure domain service. This is the same issue you'd flag in DocumentService if it grew this way.

  2. COALESCE(publishedAt, updatedAt) DESC does not use the partial index. idx_geschichten_published is (published_at DESC) WHERE status = 'PUBLISHED'. As soon as the ORDER BY is on COALESCE(published_at, updated_at), PostgreSQL needs an expression sort that the index cannot satisfy — even for the published-only path. Either:

    • Order by published_at DESC for the reader path (the only path that's bandwidth-sensitive), and accept a separate sort-by-updated_at for the BLOG_WRITE path
    • Or add a second index (COALESCE(published_at, updated_at) DESC) if you genuinely need the unified ordering

    Pick one and verify with EXPLAIN ANALYZE. Right now the index is dead code for the most common query.

Concerns

  1. In-memory limit clamplist() calls findAll(spec, Sort.unsorted()).stream().limit(safeLimit). That fetches all matching rows then trims in Java. With unbounded growth this becomes a real memory issue. Use PageRequest.of(0, safeLimit, sort) and call the findAll(Specification, Pageable) overload — push the LIMIT to PostgreSQL. The Sort.unsorted() here is also wrong — it discards the orderBy you set on the CriteriaQuery? Actually no, the query-level orderBy survives, but the call signature implies otherwise. Be explicit.

  2. @ManyToMany(fetch = EAGER) on both persons and documents — for the index page that lists 50 stories you now issue 51 queries (1 for stories + 50 for the join collections, or worse a cartesian product). A reader hits this on every page load. Use LAZY fetch and rely on @EntityGraph or DTO projection in the list-path. The detail-path (getById) can hydrate them.

  3. DTO doubles as create AND update with all fields optional — works, but the create path still requires title, validated imperatively in requireTitle(). Add a @JsonView or split into GeschichteCreateDTO / GeschichteUpdateDTO — readers of the controller currently can't tell from the type which fields are required when. This is the kind of deferred clarity that bites in 6 months.

LGTM

  • ErrorCode.GESCHICHTE_NOT_FOUND instead of FORBIDDEN to avoid leaking DRAFT existence — exactly the right call. ADR-worthy decision; please add a one-paragraph note in docs/adr/ explaining the choice.
  • V58 schema with PK constraints, FKs, ON DELETE CASCADE on join tables — textbook.
  • The integration test proves AND-not-OR semantics with a 3-story matrix. That's the right level of confidence.
  • New permission BLOG_WRITE enum value is properly typed; V59 migration grants it to existing WRITE_ALL groups so the feature isn't dark on first deploy. Smart.

Disagreement / Note

  • The GeschichteSpecifications.orderByDisplayDateDesc() no-op-with-side-effect pattern is clever but I dislike specs that mutate the query. Prefer applying the sort via Sort on the repository call. This is style-only — works as written.

— Markus

🤖 Persona review by Markus Keller (architect)

## Markus Keller — Senior Application Architect **Verdict: Approved with concerns** A clean monolith-shaped feature. New `geschichten/` controller-service-repository triplet, integrity pushed down to PostgreSQL via FKs and `ON DELETE CASCADE`, partial index for the hot path, AND-filter via Criteria EXISTS subqueries — that's the right shape. A few things I want before I sign off without reservation. ### Blockers 1. **Layer-leak in `GeschichteService.create/update`**. `currentUser()` and `currentUserHasBlogWrite()` reach into `SecurityContextHolder` directly inside a service method. We have an established `@RequirePermission` AOP for authorization and a `userService.findByEmail()` for "who am I" — but the *Authentication lookup itself* in service code is a boundary leak. It makes the service untestable without setting up SecurityContext (your own `GeschichteServiceTest` confirms this — every test calls `authenticateAs()`). Extract to a `CurrentUserService` (or pass the principal in from the controller) so `GeschichteService` stays a pure domain service. This is the same issue you'd flag in `DocumentService` if it grew this way. 2. **`COALESCE(publishedAt, updatedAt) DESC` does not use the partial index**. `idx_geschichten_published` is `(published_at DESC) WHERE status = 'PUBLISHED'`. As soon as the ORDER BY is on `COALESCE(published_at, updated_at)`, PostgreSQL needs an expression sort that the index cannot satisfy — even for the published-only path. Either: - Order by `published_at DESC` for the reader path (the only path that's bandwidth-sensitive), and accept a separate sort-by-`updated_at` for the BLOG_WRITE path - Or add a second index `(COALESCE(published_at, updated_at) DESC)` if you genuinely need the unified ordering Pick one and verify with `EXPLAIN ANALYZE`. Right now the index is dead code for the most common query. ### Concerns 3. **In-memory limit clamp** — `list()` calls `findAll(spec, Sort.unsorted()).stream().limit(safeLimit)`. That fetches *all* matching rows then trims in Java. With unbounded growth this becomes a real memory issue. Use `PageRequest.of(0, safeLimit, sort)` and call the `findAll(Specification, Pageable)` overload — push the `LIMIT` to PostgreSQL. The `Sort.unsorted()` here is also wrong — it discards the `orderBy` you set on the CriteriaQuery? Actually no, the query-level orderBy survives, but the call signature implies otherwise. Be explicit. 4. **`@ManyToMany(fetch = EAGER)` on both `persons` and `documents`** — for the index page that lists 50 stories you now issue 51 queries (1 for stories + 50 for the join collections, or worse a cartesian product). A reader hits this on every page load. Use LAZY fetch and rely on `@EntityGraph` or DTO projection in the list-path. The detail-path (`getById`) can hydrate them. 5. **DTO doubles as create AND update with all fields optional** — works, but the create path still requires `title`, validated imperatively in `requireTitle()`. Add a `@JsonView` or split into `GeschichteCreateDTO` / `GeschichteUpdateDTO` — readers of the controller currently can't tell from the type which fields are required when. This is the kind of deferred clarity that bites in 6 months. ### LGTM - `ErrorCode.GESCHICHTE_NOT_FOUND` instead of `FORBIDDEN` to avoid leaking DRAFT existence — exactly the right call. ADR-worthy decision; please add a one-paragraph note in `docs/adr/` explaining the choice. - `V58` schema with PK constraints, FKs, `ON DELETE CASCADE` on join tables — textbook. - The integration test proves AND-not-OR semantics with a 3-story matrix. That's the right level of confidence. - New permission `BLOG_WRITE` enum value is properly typed; `V59` migration grants it to existing `WRITE_ALL` groups so the feature isn't dark on first deploy. Smart. ### Disagreement / Note - The `GeschichteSpecifications.orderByDisplayDateDesc()` no-op-with-side-effect pattern is clever but I dislike specs that mutate the query. Prefer applying the sort via `Sort` on the repository call. This is style-only — works as written. — Markus 🤖 Persona review by Markus Keller (architect)
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with concerns

Big PR, well-decomposed, TDD evidence is visible (20 service tests + 12 controller slice tests + 1 integration + frontend component specs). The visual-region split is right — GeschichteEditor, GeschichtenCard, DocumentMultiSelect are each one nameable thing. A few things I'd want fixed before merge.

Blockers

  1. /api/documents/search raw fetch in DocumentMultiSelect.svelte (line 46) — the project rule from CLAUDE.md and our team style is "data flows from +page.server.ts via props — never client-side API fetch". Even though PersonMultiSelect does the same thing, this PR adds a new component with the anti-pattern, doubling the surface area. The chip-typeahead is interactive so server-load isn't the right answer, but the call should at least go via $lib/api.server or — since this is a browser-only interactive picker — keep the fetch but document the exemption with a comment. As written, an audit grep for "missing-server-load-anti-pattern" misses this. Either add the comment or factor into an existing helper.

  2. Editor.onUpdate writes to a reactive $state — fine for tracking, but it then drives a dirty = true write inside Tiptap's onTransaction callback, which fires every selection change. The comment "// onSelectionUpdate" / "// onTransaction" pattern bumps toolbarVersion even when content didn't change. That works but it's an extra render per cursor move. isActive() reads the bumped state — turn it into $derived.by(() => editor && editor.isActive(...)) and remove the manual version counter. The whole void toolbarVersion trick is what $derived exists for.

  3. title input in GeschichteEditor.svelte has no autocomplete attribute and no <label> — placeholder is not a label (Leonie will catch this too). Wrap it in a real <label> for screen readers and form autofill.

Concerns

  1. Components over 60 lines without splitting: GeschichteEditor.svelte is 329 lines. The toolbar (~70 lines), the editor body (~30), the sidebar (~40), and the save bar (~50) are four distinct visual regions. Extract EditorToolbar.svelte, EditorSaveBar.svelte. The orchestrator drops to ~150 lines and each piece is independently testable.

  2. Tiptap initialised on onMount with no fallback for bodyEditor is created with content: body but body is $state(geschichte?.body ?? ''). After mount, editor.getHTML() in onUpdate overwrites body. If editor fails to initialise (e.g. import error in production), the form silently submits whatever was in body at mount time. Add a guard so the publish/save buttons are disabled until editor is non-null, otherwise the user thinks they're saving their typed content but isn't.

  3. save() posts when titleEmpty returns synchronously — this is fine, but onSubmit is awaited inside save() and during the await the user can click again. Disable the buttons during submitting (you already do for one button — apply consistently to both DRAFT/PUBLISH).

  4. Frontend page-level error handling for the new page: handleSubmit does await res.json().catch(() => ({})) and reads code from possibly-{}. If the body is empty the user gets a generic error — acceptable, but the same pattern in the rest of the codebase uses parseBackendError(res) from $lib/errors.ts. Use the helper for consistency.

  5. extractText.ts regex fallback is documented as "not a sanitiser" — good — but on the server it is what runs (DOMParser is undefined in node). The Geschichten card excerpt code-path does plainExcerpt(g.body) on SSR. The body is already DOMPurified at render-time on the detail page, but for the card, plainExcerpt runs on raw body from the API. This is fine because the backend OWASP sanitiser already ran on save — but please add a one-line comment in GeschichtenCard.svelte confirming that assumption so the next reader doesn't second-guess it.

LGTM

  • TDD evidence is strong. Backend GeschichteServiceTest covers happy path, sanitization, status transitions, and not-found cases. The integration test exercises the AND filter with a 3-story matrix.
  • safeHtml() + extractText() correctly separated — one is a sanitizer, the other a text extractor with a clear docstring saying "not a sanitiser".
  • getErrorMessage mapping for GESCHICHTE_NOT_FOUND added in all three locales.
  • Form pre-fill via query param + silently ignoring unknown ids in geschichten/new/+page.server.ts matches the privacy rationale.

Disagreement

  • The PR description says Tiptap is mandatory per user instruction. I'd have preferred a textarea + Markdown for v1 and a richer editor in v2 — but that's a product call, not mine.

— Felix

🤖 Persona review by Felix Brandt (developer)

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with concerns** Big PR, well-decomposed, TDD evidence is visible (20 service tests + 12 controller slice tests + 1 integration + frontend component specs). The visual-region split is right — `GeschichteEditor`, `GeschichtenCard`, `DocumentMultiSelect` are each one nameable thing. A few things I'd want fixed before merge. ### Blockers 1. **`/api/documents/search` raw fetch in `DocumentMultiSelect.svelte` (line 46)** — the project rule from CLAUDE.md and our team style is "data flows from `+page.server.ts` via props — never client-side API fetch". Even though `PersonMultiSelect` does the same thing, this PR adds a *new* component with the anti-pattern, doubling the surface area. The chip-typeahead is interactive so server-load isn't the right answer, but the call should at least go via `$lib/api.server` or — since this is a browser-only interactive picker — keep the fetch but document the exemption with a comment. As written, an audit grep for "missing-server-load-anti-pattern" misses this. Either add the comment or factor into an existing helper. 2. **`Editor.onUpdate` writes to a reactive `$state`** — fine for tracking, but it then drives a `dirty = true` write inside Tiptap's `onTransaction` callback, which fires *every selection change*. The comment "// onSelectionUpdate" / "// onTransaction" pattern bumps `toolbarVersion` even when content didn't change. That works but it's an extra render per cursor move. `isActive()` reads the bumped state — turn it into `$derived.by(() => editor && editor.isActive(...))` and remove the manual version counter. The whole `void toolbarVersion` trick is what `$derived` exists for. 3. **`title` input in `GeschichteEditor.svelte`** has no `autocomplete` attribute and no `<label>` — placeholder is not a label (Leonie will catch this too). Wrap it in a real `<label>` for screen readers and form autofill. ### Concerns 4. **Components over 60 lines without splitting**: `GeschichteEditor.svelte` is 329 lines. The toolbar (~70 lines), the editor body (~30), the sidebar (~40), and the save bar (~50) are four distinct visual regions. Extract `EditorToolbar.svelte`, `EditorSaveBar.svelte`. The orchestrator drops to ~150 lines and each piece is independently testable. 5. **Tiptap initialised on `onMount` with no fallback for `body`** — `Editor` is created with `content: body` but `body` is `$state(geschichte?.body ?? '')`. After mount, `editor.getHTML()` in `onUpdate` overwrites `body`. If `editor` fails to initialise (e.g. import error in production), the form silently submits whatever was in `body` at mount time. Add a guard so the publish/save buttons are disabled until `editor` is non-null, otherwise the user thinks they're saving their typed content but isn't. 6. **`save()` posts when `titleEmpty` returns synchronously** — this is fine, but `onSubmit` is awaited inside `save()` and during the await the user can click again. Disable the buttons during `submitting` (you already do for one button — apply consistently to both DRAFT/PUBLISH). 7. **Frontend page-level error handling for the `new` page**: `handleSubmit` does `await res.json().catch(() => ({}))` and reads `code` from possibly-`{}`. If the body is empty the user gets a generic error — acceptable, but the same pattern in the rest of the codebase uses `parseBackendError(res)` from `$lib/errors.ts`. Use the helper for consistency. 8. **`extractText.ts` regex fallback** is documented as "not a sanitiser" — good — but on the server it *is* what runs (`DOMParser` is undefined in node). The Geschichten card excerpt code-path does `plainExcerpt(g.body)` on SSR. The body is already DOMPurified at render-time on the detail page, but for the *card*, `plainExcerpt` runs on raw `body` from the API. This is fine because the backend OWASP sanitiser already ran on save — but please add a one-line comment in `GeschichtenCard.svelte` confirming that assumption so the next reader doesn't second-guess it. ### LGTM - TDD evidence is strong. Backend `GeschichteServiceTest` covers happy path, sanitization, status transitions, and not-found cases. The integration test exercises the AND filter with a 3-story matrix. - `safeHtml()` + `extractText()` correctly separated — one is a sanitizer, the other a text extractor with a clear docstring saying "not a sanitiser". - `getErrorMessage` mapping for `GESCHICHTE_NOT_FOUND` added in all three locales. - Form pre-fill via query param + silently ignoring unknown ids in `geschichten/new/+page.server.ts` matches the privacy rationale. ### Disagreement - The PR description says Tiptap is mandatory per user instruction. I'd have preferred a textarea + Markdown for v1 and a richer editor in v2 — but that's a product call, not mine. — Felix 🤖 Persona review by Felix Brandt (developer)
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Infra footprint is light — two new dependencies, two Flyway migrations, no new services or volumes. Nothing in the Compose stack changes. The dependency choices are sane and version-pinned.

LGTM

  • owasp-java-html-sanitizer:20240325.1 pinned in backend/pom.xml. Dependency-Check will pick this up if a CVE lands.
  • isomorphic-dompurify:^3.12.0 in frontend/package.json. The caret is consistent with the rest of the file.
  • V58__add_geschichten.sql and V59__seed_blog_write.sql follow the existing Flyway naming convention. V59 is idempotent (NOT EXISTS-guarded) — good for re-runs against partially seeded environments.
  • Partial index idx_geschichten_published is well-targeted to the hot path, even if Markus is right that the COALESCE ORDER BY won't use it.

Concerns

  1. isomorphic-dompurify ships JSDOM — peek at npm install's warning output. JSDOM is a heavy SSR-only dependency (~5MB). On the SvelteKit Node adapter at runtime it loads on every server-render of /geschichten/[id]. Worth profiling: cold start time and resident set size before/after. If it's significant we can swap to plain dompurify for the client and sanitize-html for the server — same allow-list, smaller server footprint.

  2. No BLOG_WRITE documentation in the admin UI. The seed migration grants it to groups that already had WRITE_ALL, but the admin group editor (presumably) shows the permission as a checkbox. If the i18n keys for "BLOG_WRITE" are not added to the group permission picker labels, admins see a raw enum value. Worth a quick check — not infra strictly speaking, but it's a deploy-day rough edge.

  3. No CI test for the Flyway migrations themselves — V58/V59 will run when the next deploy hits production. The repo runs Testcontainers PostgreSQL in CI, which executes all migrations at integration-test time, so this is implicitly covered. Still, add a smoke note in the PR body confirming ./mvnw verify passed against a clean DB.

Suggestions

  • Renovate will eventually want to bump owasp-java-html-sanitizer. The package release cadence is irregular; pin major+minor (you already do — 20240325.1) and let Renovate batch-bump.
  • No environment variables added — clean.
  • No port exposures or new healthchecks needed — clean.

— Tobi

🤖 Persona review by Tobias Wendt (devops)

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Infra footprint is light — two new dependencies, two Flyway migrations, no new services or volumes. Nothing in the Compose stack changes. The dependency choices are sane and version-pinned. ### LGTM - `owasp-java-html-sanitizer:20240325.1` pinned in `backend/pom.xml`. Dependency-Check will pick this up if a CVE lands. - `isomorphic-dompurify:^3.12.0` in `frontend/package.json`. The caret is consistent with the rest of the file. - `V58__add_geschichten.sql` and `V59__seed_blog_write.sql` follow the existing Flyway naming convention. V59 is idempotent (`NOT EXISTS`-guarded) — good for re-runs against partially seeded environments. - Partial index `idx_geschichten_published` is well-targeted to the hot path, even if Markus is right that the COALESCE ORDER BY won't use it. ### Concerns 1. **`isomorphic-dompurify` ships JSDOM** — peek at `npm install`'s warning output. JSDOM is a heavy SSR-only dependency (~5MB). On the SvelteKit Node adapter at runtime it loads on every server-render of `/geschichten/[id]`. Worth profiling: cold start time and resident set size before/after. If it's significant we can swap to plain `dompurify` for the client and `sanitize-html` for the server — same allow-list, smaller server footprint. 2. **No `BLOG_WRITE` documentation in the admin UI**. The seed migration grants it to groups that already had `WRITE_ALL`, but the admin group editor (presumably) shows the permission as a checkbox. If the i18n keys for "BLOG_WRITE" are not added to the group permission picker labels, admins see a raw enum value. Worth a quick check — not infra strictly speaking, but it's a deploy-day rough edge. 3. **No CI test for the Flyway migrations themselves** — V58/V59 will run when the next deploy hits production. The repo runs Testcontainers PostgreSQL in CI, which executes all migrations at integration-test time, so this is implicitly covered. Still, add a smoke note in the PR body confirming `./mvnw verify` passed against a clean DB. ### Suggestions - Renovate will eventually want to bump `owasp-java-html-sanitizer`. The package release cadence is irregular; pin major+minor (you already do — `20240325.1`) and let Renovate batch-bump. - No environment variables added — clean. - No port exposures or new healthchecks needed — clean. — Tobi 🤖 Persona review by Tobias Wendt (devops)
Author
Owner

Elicit — Senior Requirements Engineer

Verdict: Approved with concerns

This is brownfield mode work. The PR closes #381 and the description maps cleanly to the original ACs (US-BLOG-001..006) plus the spec expansions. Backlog hygiene is good — clear scope statement, decisions encoded, test plan with verifiable steps. A few elicitation gaps surface from the artifact itself.

Backlog / Spec Hygiene

Strengths:

  • Closes a single Gitea issue with a verifiable scope statement.
  • Decisions made during implementation are documented in the PR body ("Tiptap is mandatory", "no body length limit", "≥3 threshold", etc.) — this is the right place for them. They should be lifted into either #381 itself or an ADR for traceability.
  • Test plan has actual checkable items, not just "we tested it".

Gaps:

  1. No NFR statement for body length — the description says "No body length limit (per user instruction)". This is a deliberate scope decision but it is also a non-functional requirement that needs to live somewhere persistent. Without a max-length acceptance criterion, the behavior at 10MB / 1GB / "what happens if a malicious BLOG_WRITER pastes a 100MB doc" is undefined. Recommend adding NFR-PERF-XXX:

    • "The system SHALL accept Geschichte body content up to N MB. ABOVE N MB, it SHALL respond with VALIDATION_ERROR." Pick N. Even N = "10 MB" is better than no answer.
    • Or explicitly accept "unbounded" as the requirement and track the operational risk in the tech-debt register.
  2. DRAFT-not-leaked semantics — encoded in the implementation as 404 GESCHICHTE_NOT_FOUND instead of 403. This is a security-flavored requirement and deserves an EARS rule in the issue/spec, not just a code comment:

    • REQ-GESCHICHTEN-NN: If a non-BLOG_WRITER user requests a DRAFT geschichte by id, then the system shall respond 404 GESCHICHTE_NOT_FOUND and shall not include any indicator that a draft with that id exists.
    • Same for the new-page silent-ignore on unknown personId/documentId pre-fill.
  3. Multi-person filter — empty AND case — the integration test confirms personA AND personB AND personC → [] when no story exists with all three. The ACs in #381 don't enumerate this. Add: "When the user selects N persons in the filter, the system shall display only stories associated with ALL N." This is now implemented and tested but the requirement was inferred. Lift it back to #381 for traceability.

  4. Unhappy paths missing from #381 ACs:

    • What does the user see when their session expires while editing? (Currently: API returns 401 → getErrorMessage(MISSING_CREDENTIALS) — fine, but unspecified)
    • What happens if a user deletes a person referenced in a published story? (DB has ON DELETE CASCADE on the join row — story stays, person reference disappears. Is that correct UX? Or should the story's author be notified?)
    • What happens if two BLOG_WRITERs edit the same story concurrently? (Last write wins; no optimistic locking.) Acceptable but should be stated.
  5. Accessibility AC — issue #381 (assumed) doesn't mention WCAG conformance. The implementation hits 44px touch targets and has aria-pressed on toolbar buttons, which Leonie will confirm. Lift the WCAG 2.1 AA target into the issue's NFR list so future stories inherit the standard.

Issue Quality Audit

Issue #381 (closing): I haven't re-read the issue body, but the PR description's "Decisions encoded in the implementation" block is doing the work of an ADR. Recommend either:

  • Append the decisions list to #381 as a comment (so it's preserved when the PR is squashed)
  • OR commit a short docs/adr/2026-XX-geschichten.md covering: Tiptap-vs-Markdown, no-length-limit, DRAFT-leak-policy

Suggestions

  • Future stories on top of Geschichten should reference BLOG_WRITE as the permission in their ACs, not "logged-in BLOG_WRITER" as prose. Permissions are a glossary term now.
  • The "Alle Geschichten zu {name}" footer link at ≥3 stories is a magic-number UX rule. Document it ("Display 3 most recent on parent pages; link to full list when ≥3 exist") so the threshold is reviewable.

LGTM

  • Test plan is checkable, with concrete commands and expected counts.
  • Migration impact is called out (existing groups need BLOG_WRITE; V59 handles the seed).
  • Smoke-test steps cover the full lifecycle including the privacy boundary (READ_ALL-only user cannot reach a DRAFT 404).

— Elicit

🤖 Persona review by Elicit (req_engineer)

## Elicit — Senior Requirements Engineer **Verdict: Approved with concerns** This is brownfield mode work. The PR closes #381 and the description maps cleanly to the original ACs (US-BLOG-001..006) plus the spec expansions. Backlog hygiene is good — clear scope statement, decisions encoded, test plan with verifiable steps. A few elicitation gaps surface from the artifact itself. ### Backlog / Spec Hygiene **Strengths**: - Closes a single Gitea issue with a verifiable scope statement. - Decisions made during implementation are documented in the PR body ("Tiptap is mandatory", "no body length limit", "≥3 threshold", etc.) — this is the right place for them. They should be lifted into either #381 itself or an ADR for traceability. - Test plan has actual checkable items, not just "we tested it". **Gaps**: 1. **No NFR statement for body length** — the description says "No body length limit (per user instruction)". This is a deliberate scope decision but it is also a non-functional requirement that needs to live somewhere persistent. Without a max-length acceptance criterion, the behavior at 10MB / 1GB / "what happens if a malicious BLOG_WRITER pastes a 100MB doc" is undefined. Recommend adding NFR-PERF-XXX: - "The system SHALL accept Geschichte body content up to N MB. ABOVE N MB, it SHALL respond with VALIDATION_ERROR." Pick N. Even N = "10 MB" is better than no answer. - Or explicitly accept "unbounded" as the requirement and track the operational risk in the tech-debt register. 2. **DRAFT-not-leaked semantics** — encoded in the implementation as `404 GESCHICHTE_NOT_FOUND` instead of `403`. This is a security-flavored requirement and deserves an EARS rule in the issue/spec, not just a code comment: - REQ-GESCHICHTEN-NN: *If* a non-BLOG_WRITER user requests a DRAFT geschichte by id, *then* the system shall respond `404 GESCHICHTE_NOT_FOUND` and shall not include any indicator that a draft with that id exists. - Same for the new-page silent-ignore on unknown personId/documentId pre-fill. 3. **Multi-person filter — empty AND case** — the integration test confirms `personA AND personB AND personC → []` when no story exists with all three. The ACs in #381 don't enumerate this. Add: "When the user selects N persons in the filter, the system shall display only stories associated with ALL N." This is now implemented and tested but the requirement was inferred. Lift it back to #381 for traceability. 4. **Unhappy paths missing from #381 ACs**: - What does the user see when their session expires while editing? (Currently: API returns 401 → `getErrorMessage(MISSING_CREDENTIALS)` — fine, but unspecified) - What happens if a user deletes a person referenced in a published story? (DB has `ON DELETE CASCADE` on the join row — story stays, person reference disappears. Is that correct UX? Or should the story's author be notified?) - What happens if two BLOG_WRITERs edit the same story concurrently? (Last write wins; no optimistic locking.) Acceptable but should be stated. 5. **Accessibility AC** — issue #381 (assumed) doesn't mention WCAG conformance. The implementation hits 44px touch targets and has aria-pressed on toolbar buttons, which Leonie will confirm. Lift the WCAG 2.1 AA target into the issue's NFR list so future stories inherit the standard. ### Issue Quality Audit **Issue #381** (closing): I haven't re-read the issue body, but the PR description's "Decisions encoded in the implementation" block is doing the work of an ADR. Recommend either: - Append the decisions list to #381 as a comment (so it's preserved when the PR is squashed) - OR commit a short `docs/adr/2026-XX-geschichten.md` covering: Tiptap-vs-Markdown, no-length-limit, DRAFT-leak-policy ### Suggestions - Future stories on top of Geschichten should reference `BLOG_WRITE` as the permission in their ACs, not "logged-in BLOG_WRITER" as prose. Permissions are a glossary term now. - The "Alle Geschichten zu {name}" footer link at ≥3 stories is a magic-number UX rule. Document it ("Display 3 most recent on parent pages; link to full list when ≥3 exist") so the threshold is reviewable. ### LGTM - Test plan is checkable, with concrete commands and expected counts. - Migration impact is called out (existing groups need BLOG_WRITE; V59 handles the seed). - Smoke-test steps cover the full lifecycle including the privacy boundary (READ_ALL-only user cannot reach a DRAFT 404). — Elicit 🤖 Persona review by Elicit (req_engineer)
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

XSS surface is well-defended in depth: backend OWASP allow-list on save, frontend DOMPurify on render, allow-lists are aligned, regression tests cover the obvious payloads. Authorization is declarative via @RequirePermission. The DRAFT-not-leaked policy is correctly implemented as 404. A few items I'd want addressed.

Concerns

  1. extractText() server-side fallback is regex-based (html.replace(/<[^>]*>/g, '')). The module docstring is honest about this — "not a sanitiser, do not use to defend against XSS" — and safeHtml() is the only XSS sanitizer. However, plainExcerpt() is called on g.body in GeschichtenCard.svelte and +page.svelte (the index page) inside SSR. The body has been backend-sanitized, so this is currently safe. But the regex strip is brittle — <script>alert(1)</script> becomes alert(1) text, which then gets injected into the DOM as text content (safe). But constructions like <img src="<script>" would expose mismatched fragments to the regex. Recommend swapping the regex fallback for parse5 text-extraction or just using dompurify again with RETURN_DOM_FRAGMENT to fetch text. This is defense-in-depth, not an exploit today.

  2. Title is not sanitized at all. The body goes through OWASP sanitizer; the title goes through .trim(). If a title contains <script>, it'll be stored as-is and rendered via {g.title} (Svelte auto-escapes — safe). But the title is also concatenated into URLs and aria-labels in places (e.g., the geschichten_filter_remove_chip translation interpolates {name}). Confirm Paraglide's interpolation also escapes — I believe it does, but verify. If it doesn't, a maliciously-titled chip label could break out of the aria-label. Fix: either run titles through a stricter Jsoup.text() strip on save, or add an integration test that confirms a <script>-titled story renders as escaped text in every place the title appears.

  3. @ManyToMany(fetch = EAGER) on Geschichte.documents and Geschichte.persons — when a reader fetches a published story, the API returns the full Document and Person payloads. Document likely contains fields the reader shouldn't see if access control varies by document (it doesn't here, but the principle holds). For the list endpoint, reader sees N stories × all persons × all documents — an unauthenticated information aggregation surface if the auth wrapper is ever bypassed. Recommend a GeschichteSummaryDTO for the list path with only id, title, publishedAt, author.{firstName,lastName} — no person/document chip data. Hydrate on detail.

  4. Author email leaks via authorName() fallback in GeschichtenCard.svelte and the index page: when the author has no firstName/lastName, the full email is rendered. Family member email addresses are personal data under GDPR. Either:

    • Always require firstName+lastName on AppUser
    • Fall back to email.split('@')[0] (the local part) as a username
    • Or fall back to "(Familienarchiv-Benutzer)"

    Don't render the literal email.

  5. No CSRF protection on /api/geschichten POST/PATCH/DELETE. Per the rest of the project, CSRF is disabled because auth is via Authorization header set from a cookie via the SSR proxy (per the security-comment-pattern in CLAUDE.md). I trust this is consistent project-wide, but please confirm by adding a single sentence in the controller class's javadoc: "CSRF: disabled project-wide; see SecurityConfig — auth via Bearer header set by SvelteKit SSR proxy."

LGTM

  • BLOG_WRITE is a typed enum value, not a magic string. Compile-time-checked at every annotation site.
  • DRAFT-not-leaked: returns 404 GESCHICHTE_NOT_FOUND not 403 FORBIDDEN. This is the correct decision and the test getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE codifies it as a regression test. Same for the new-page silent-ignore.
  • Allow-lists on backend (OWASP) and frontend (DOMPurify) are exactly identical: p, br, strong, em, h2, h3, ul, ol, li. No event-handler attributes allowed. No <a>, <img>, no javascript: URL surface.
  • create_sanitizes_body_HTML_dropping_disallowed_tags() test asserts on <script>, onerror, <img> — covers the obvious payloads. Add <svg/onload> and <math><annotation> for completeness.
  • @RequirePermission(BLOG_WRITE) correctly applied to POST/PATCH/DELETE; GETs are open to authenticated users (READ_ALL is enforced by the global anyRequest().authenticated()).
  • The 403 vs 404 distinction is tested in the controller test — both paths verified.

Detection

Add a Semgrep rule to the CI baseline to catch future @PostMapping / @PatchMapping / @DeleteMapping without an accompanying @RequirePermission. Rule template:

rules:
  - id: missing-require-permission
    pattern-either:
      - patterns:
        - pattern: |
            @$WRITE_METHOD(...)
            $RET $METHOD(...) { ... }
        - metavariable-regex:
            metavariable: $WRITE_METHOD
            regex: "(PostMapping|PatchMapping|PutMapping|DeleteMapping)"
        - pattern-not-inside: |
            @RequirePermission(...)
            ...
    message: "Write endpoint missing @RequirePermission annotation"
    severity: ERROR

— Nora

🤖 Persona review by Nora Steiner (security_expert)

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved with concerns** XSS surface is well-defended in depth: backend OWASP allow-list on save, frontend DOMPurify on render, allow-lists are aligned, regression tests cover the obvious payloads. Authorization is declarative via `@RequirePermission`. The DRAFT-not-leaked policy is correctly implemented as 404. A few items I'd want addressed. ### Concerns 1. **`extractText()` server-side fallback is regex-based** (`html.replace(/<[^>]*>/g, '')`). The module docstring is honest about this — "not a sanitiser, do not use to defend against XSS" — and `safeHtml()` is the only XSS sanitizer. However, `plainExcerpt()` is called on `g.body` in `GeschichtenCard.svelte` and `+page.svelte` (the index page) inside SSR. The body has been backend-sanitized, so this is *currently* safe. But the regex strip is brittle — `<script>alert(1)</script>` becomes `alert(1)` text, which then gets injected into the DOM as text content (safe). But constructions like `<img src="<script>"` would expose mismatched fragments to the regex. Recommend swapping the regex fallback for `parse5` text-extraction or just using `dompurify` again with `RETURN_DOM_FRAGMENT` to fetch text. This is defense-in-depth, not an exploit today. 2. **Title is not sanitized at all**. The body goes through OWASP sanitizer; the title goes through `.trim()`. If a title contains `<script>`, it'll be stored as-is and rendered via `{g.title}` (Svelte auto-escapes — safe). But the title is also concatenated into URLs and aria-labels in places (e.g., the `geschichten_filter_remove_chip` translation interpolates `{name}`). Confirm Paraglide's interpolation also escapes — I believe it does, but verify. If it doesn't, a maliciously-titled chip label could break out of the aria-label. **Fix**: either run titles through a stricter `Jsoup.text()` strip on save, or add an integration test that confirms a `<script>`-titled story renders as escaped text in every place the title appears. 3. **`@ManyToMany(fetch = EAGER)` on `Geschichte.documents` and `Geschichte.persons`** — when a reader fetches a published story, the API returns the full `Document` and `Person` payloads. `Document` likely contains fields the reader shouldn't see if access control varies by document (it doesn't here, but the principle holds). For the *list endpoint*, reader sees N stories × all persons × all documents — an unauthenticated information aggregation surface if the auth wrapper is ever bypassed. Recommend a `GeschichteSummaryDTO` for the list path with only `id, title, publishedAt, author.{firstName,lastName}` — no person/document chip data. Hydrate on detail. 4. **Author email leaks via `authorName()` fallback** in `GeschichtenCard.svelte` and the index page: when the author has no firstName/lastName, the full email is rendered. Family member email addresses are personal data under GDPR. Either: - Always require firstName+lastName on `AppUser` - Fall back to `email.split('@')[0]` (the local part) as a username - Or fall back to "(Familienarchiv-Benutzer)" Don't render the literal email. 5. **No CSRF protection on `/api/geschichten` POST/PATCH/DELETE**. Per the rest of the project, CSRF is disabled because auth is via `Authorization` header set from a cookie via the SSR proxy (per the security-comment-pattern in CLAUDE.md). I trust this is consistent project-wide, but please confirm by adding a single sentence in the controller class's javadoc: "CSRF: disabled project-wide; see SecurityConfig — auth via Bearer header set by SvelteKit SSR proxy." ### LGTM - `BLOG_WRITE` is a typed enum value, not a magic string. Compile-time-checked at every annotation site. - DRAFT-not-leaked: returns `404 GESCHICHTE_NOT_FOUND` not `403 FORBIDDEN`. This is the correct decision and the test `getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE` codifies it as a regression test. Same for the new-page silent-ignore. - Allow-lists on backend (OWASP) and frontend (DOMPurify) are exactly identical: `p, br, strong, em, h2, h3, ul, ol, li`. No event-handler attributes allowed. No `<a>`, `<img>`, no `javascript:` URL surface. - `create_sanitizes_body_HTML_dropping_disallowed_tags()` test asserts on `<script>`, `onerror`, `<img>` — covers the obvious payloads. Add `<svg/onload>` and `<math><annotation>` for completeness. - `@RequirePermission(BLOG_WRITE)` correctly applied to POST/PATCH/DELETE; GETs are open to authenticated users (READ_ALL is enforced by the global `anyRequest().authenticated()`). - The `403 vs 404` distinction is tested in the controller test — both paths verified. ### Detection Add a Semgrep rule to the CI baseline to catch future `@PostMapping` / `@PatchMapping` / `@DeleteMapping` without an accompanying `@RequirePermission`. Rule template: ```yaml rules: - id: missing-require-permission pattern-either: - patterns: - pattern: | @$WRITE_METHOD(...) $RET $METHOD(...) { ... } - metavariable-regex: metavariable: $WRITE_METHOD regex: "(PostMapping|PatchMapping|PutMapping|DeleteMapping)" - pattern-not-inside: | @RequirePermission(...) ... message: "Write endpoint missing @RequirePermission annotation" severity: ERROR ``` — Nora 🤖 Persona review by Nora Steiner (security_expert)
Author
Owner

Sara Holt — QA Engineer

Verdict: Approved with concerns

Strong test inventory: 20 service unit tests, 12 controller slice tests, 1 Testcontainers integration test, plus four frontend component spec files and a Playwright E2E. The pyramid shape is right and the integration test in particular nails the AND-filter semantics. A handful of gaps to close.

Concerns

  1. GeschichteServiceTest is unit-but-touches-SecurityContext — every test calls authenticateAs() which mutates a thread-local. @AfterEach clears it, but tests that share the JVM and run in parallel could collide. The test class is fine in isolation but flags a testability problem in GeschichteService itself (Markus already raised this — extract a CurrentUserService abstraction so service tests don't need security setup).

  2. Controller test misses the multi-person AND filter wire-format edge case:

    • list_passesRepeatedPersonIdParamsAsListForAndFilter covers two ids. Add: zero ids passed (single param empty) → service receives empty list, no filter applied. And: same id repeated → service receives [id, id] (does the spec dedupe? It doesn't currently — duplicate ids will cause the spec to add two redundant subqueries. Innocent but ugly.)
  3. Integration test list_filters_with_AND_semantics_when_multiple_personIds_given() is excellent — but it does not test the order of returned stories. The spec promises ORDER BY COALESCE(publishedAt, updatedAt) DESC. Add an assertion that storyAB (most recently created) comes first when all three are returned.

  4. E2E test multi-person filter selects persons via pickPerson('a') / pickPerson('b') — single-letter substrings rely on demo seed data containing persons with names matching /a/i and /b/i. This is fragile. Either:

    • Seed deterministic test persons (E2E-Person-Anna, E2E-Person-Bertha) in auth.setup.ts and pick by full name
    • OR mark the test test.skip if the demo seed is empty

    If the seed ever changes, this test silently passes (because both persons resolve to the same row, perhaps) or hangs at expect(firstOption).toBeVisible().

  5. AxeBuilder test gates only on serious/critical — that's a soft gate. Per Leonie's likely review, the senior-author audience needs WCAG 2.1 AA throughout, including moderate violations. Either tighten the gate now or open a follow-up issue specifically for /geschichten/[id] and /geschichten/new (the form is the highest a11y risk surface).

  6. GeschichteEditor.svelte.spec.ts doesn't test:

    • The beforeNavigate unsaved-changes guard. Easy to break, hard to detect manually.
    • The PUBLISHED-mode "Zurück zu Entwurf" button calling save('DRAFT').
    • The Tiptap toolbar buttons actually toggling marks (mock the editor and assert the chain method was called).
    • The disabled state during submitting (only one button currently uses submitting).
  7. DocumentMultiSelect.svelte.spec.ts mocks fetch globally — fine — but doesn't test the abort/race case where the user types fast: result A arrives after result B. The current debounce is 300ms; a slow API + fast typist still races. Add a test that enqueues two responses out-of-order and asserts the latest query's results win (or document that the debounce alone is the contract).

  8. No frontend test for /geschichten/[id]/+page.svelte — no spec file for the detail page. The DOMPurify safeHtml call, the delete-with-confirm flow, and the BLOG_WRITER edit/delete buttons all need coverage. The Playwright E2E touches happy-path navigation but unit-level tests would catch the sanitizer wiring much faster.

LGTM

  • @WebMvcTest(GeschichteController.class) + @Import({SecurityConfig.class, PermissionAspect.class}) is exactly the right slice — fast, real auth, real permission AOP.
  • Testcontainers integration test uses real PostgreSQL (no H2!) and proves AND filter via three stories with overlapping persons. Excellent design.
  • Helper factories (makeStory, personFactory, docFactory) are reusable and override-friendly.
  • Test names are sentences. should_throw_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE reads as documentation.
  • Backend service test asserts on ErrorCode directly (extracting("code").isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND)) — beats string-matching.

Coverage Estimate

I'd estimate this PR at roughly 90%+ branch on the new backend code. The frontend new-files coverage is more like 70-80% — the editor's PUBLISHED-mode interactions and the detail-page delete-confirm are the obvious gaps. Run npm run test:coverage and post the delta if convenient.

— Sara

🤖 Persona review by Sara Holt (tester)

## Sara Holt — QA Engineer **Verdict: Approved with concerns** Strong test inventory: 20 service unit tests, 12 controller slice tests, 1 Testcontainers integration test, plus four frontend component spec files and a Playwright E2E. The pyramid shape is right and the integration test in particular nails the AND-filter semantics. A handful of gaps to close. ### Concerns 1. **`GeschichteServiceTest` is unit-but-touches-SecurityContext** — every test calls `authenticateAs()` which mutates a thread-local. `@AfterEach` clears it, but tests that share the JVM and run in parallel could collide. The test class is fine in isolation but flags a testability problem in `GeschichteService` itself (Markus already raised this — extract a `CurrentUserService` abstraction so service tests don't need security setup). 2. **Controller test misses the multi-person AND filter wire-format edge case**: - `list_passesRepeatedPersonIdParamsAsListForAndFilter` covers two ids. Add: zero ids passed (single param empty) → service receives empty list, no filter applied. And: same id repeated → service receives `[id, id]` (does the spec dedupe? It doesn't currently — duplicate ids will cause the spec to add two redundant subqueries. Innocent but ugly.) 3. **Integration test `list_filters_with_AND_semantics_when_multiple_personIds_given()` is excellent** — but it does not test the order of returned stories. The spec promises `ORDER BY COALESCE(publishedAt, updatedAt) DESC`. Add an assertion that `storyAB` (most recently created) comes first when all three are returned. 4. **E2E test `multi-person filter` selects persons via `pickPerson('a')` / `pickPerson('b')`** — single-letter substrings rely on demo seed data containing persons with names matching `/a/i` and `/b/i`. This is fragile. Either: - Seed deterministic test persons (`E2E-Person-Anna`, `E2E-Person-Bertha`) in `auth.setup.ts` and pick by full name - OR mark the test `test.skip` if the demo seed is empty If the seed ever changes, this test silently passes (because both persons resolve to the same row, perhaps) or hangs at `expect(firstOption).toBeVisible()`. 5. **`AxeBuilder` test gates only on `serious`/`critical`** — that's a soft gate. Per Leonie's likely review, the senior-author audience needs WCAG 2.1 AA throughout, including `moderate` violations. Either tighten the gate now or open a follow-up issue specifically for `/geschichten/[id]` and `/geschichten/new` (the form is the highest a11y risk surface). 6. **`GeschichteEditor.svelte.spec.ts`** doesn't test: - The `beforeNavigate` unsaved-changes guard. Easy to break, hard to detect manually. - The PUBLISHED-mode "Zurück zu Entwurf" button calling `save('DRAFT')`. - The Tiptap toolbar buttons actually toggling marks (mock the editor and assert the chain method was called). - The disabled state during `submitting` (only one button currently uses `submitting`). 7. **`DocumentMultiSelect.svelte.spec.ts`** mocks `fetch` globally — fine — but doesn't test the abort/race case where the user types fast: result A arrives after result B. The current debounce is 300ms; a slow API + fast typist still races. Add a test that enqueues two responses out-of-order and asserts the latest query's results win (or document that the debounce alone is the contract). 8. **No frontend test for `/geschichten/[id]/+page.svelte`** — no spec file for the detail page. The DOMPurify safeHtml call, the delete-with-confirm flow, and the BLOG_WRITER edit/delete buttons all need coverage. The Playwright E2E touches happy-path navigation but unit-level tests would catch the sanitizer wiring much faster. ### LGTM - `@WebMvcTest(GeschichteController.class)` + `@Import({SecurityConfig.class, PermissionAspect.class})` is exactly the right slice — fast, real auth, real permission AOP. - Testcontainers integration test uses real PostgreSQL (no H2!) and proves AND filter via three stories with overlapping persons. Excellent design. - Helper factories (`makeStory`, `personFactory`, `docFactory`) are reusable and override-friendly. - Test names are sentences. `should_throw_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE` reads as documentation. - Backend service test asserts on `ErrorCode` directly (`extracting("code").isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND)`) — beats string-matching. ### Coverage Estimate I'd estimate this PR at roughly 90%+ branch on the new backend code. The frontend new-files coverage is more like 70-80% — the editor's PUBLISHED-mode interactions and the detail-page delete-confirm are the obvious gaps. Run `npm run test:coverage` and post the delta if convenient. — Sara 🤖 Persona review by Sara Holt (tester)
Author
Owner

Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved with concerns

Solid baseline accessibility work — 44px touch targets on toolbar buttons, aria-label and aria-pressed on every formatting button, aria-invalid and aria-describedby on the title field, semantic <article> and landmark headings. The senior-author persona was clearly considered. A few specific issues that need fixing for the dual-audience promise.

Blockers

  1. Title input has no <label> (GeschichteEditor.svelte line 148). The placeholder "Titel der Geschichte" is the only label. WCAG 1.3.1 requires programmatic labels for form fields; <input placeholder> is not a label. Screen reader users get edit text with no field name. Fix:

    <label for="geschichte-title" class="sr-only">{m.geschichte_editor_title_placeholder()}</label>
    <input id="geschichte-title" type="text" bind:value={title} ... />
    

    Or wrap the input in <label>. Same fix needed in DocumentMultiSelect.svelte and PersonMultiSelect.svelte (existing issue, but if you're touching the surface…).

  2. Toolbar role="toolbar" lacks keyboard navigation (line 167). The ARIA toolbar pattern requires arrow-key navigation between buttons (W3C ARIA APG: Toolbar). Currently each button is a tab stop, which is wrong for a toolbar — it should be one tab stop with arrow keys to move between buttons. For a 6-button formatting bar this is enforceable. Either:

    • Implement the Roving tabindex pattern (one button has tabindex=0, others tabindex=-1, arrow keys move focus)
    • OR remove role="toolbar" and just style as a <div>. Tab-cycling 6 toolbar buttons before reaching the body is friction for the senior keyboard user.
  3. DropDown in DocumentMultiSelect is <div role="button"> keyboard-handled by onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)} (line 137-138) — no Space, no Arrow keys, no Escape, no Home/End. The combobox/listbox pattern requires all of those. Use a <button> element (gets Enter and Space for free) and add the listbox role properly. Existing project pattern in PersonMultiSelect has the same flaw, so this is a pre-existing weakness, but it's worth fixing while the file is fresh.

Concerns

  1. Author email rendered when firstName/lastName are missing. In GeschichtenCard.svelte and geschichten/+page.svelte:

    return full || a.email || '';
    

    This shows the user's full email address as a byline. Privacy concern (Nora flagged it too) AND a UX concern — users don't want their email appearing as a byline next to family stories. Use email.split('@')[0] or a generic fallback.

  2. Detail page body width is max-w-3xl (768px). The comment in the file justifies it ("Tinos at full width is more readable for senior-authors than prose's 65ch clamp"). At 320px viewport this is fine because the parent mx-auto max-w-3xl shrinks. At 1440px, 768px reading width is still appropriate. Good call. Verify the body line-height (leading-relaxed = 1.625) against font-serif at text-lg (18px) renders at ~28-29px line height for the senior persona — should be comfortable.

  3. Filter pills at 36px height (h-9) miss the 44px touch target requirement. WCAG 2.5.5 (AAA) and 2.5.8 (AA, new in 2.2) require 24px minimum but 44px is the practical senior target. The toolbar buttons are 44px (h-11 min-w-[44px]); the filter pills should match. Bump to h-11.

  4. aria-pressed="true" chip with no clear visual focus indicator on hover or focus. The active filter chips at bg-ink text-primary-fg are visually distinct in pressed state but :hover and :focus-visible don't add anything. Add focus-visible:ring-2 focus-visible:ring-focus-ring.

  5. Save bar is position: sticky bottom-0 (line 285) which is right for a long form. Verify on iOS Safari — the URL bar collapse can cause sticky elements to jump. If it does, add pb-[env(safe-area-inset-bottom)].

  6. Card pattern deviation. The project's card pattern is bg-white shadow-sm border border-brand-sand rounded-sm p-6. The new editor uses bg-surface ... rounded (not rounded-sm). Tiny inconsistency; either align to the canonical pattern or document the deviation in docs/specs/.

Suggestions

  1. No skeleton/loading state while the Tiptap editor initializes. On a slow network, the editor surface is empty for 200-500ms. Add a subtle "Lädt Editor…" placeholder inside editorEl.

  2. No empty-state for the document picker dropdown — when search returns 0 results, the dropdown closes silently. Add "Keine Treffer für '{searchTerm}'" so users know the search ran.

  3. Mobile layout test: the editor's lg:grid-cols-[2fr_1fr] collapses to a single column below 1024px — good. But the sidebar sections (Status, Personen, Dokumente) stack vertically and push the editor body far down. On a 768px tablet (a likely BLOG_WRITER device), the editor body is below the fold. Consider keeping a 2-column layout from md: upward.

  4. Author byline byte order. Most German genealogy contexts say "Nachname, Vorname" for formal, "Vorname Nachname" for casual. The card uses [firstName, lastName].filter(Boolean).join(' ') — Vorname Nachname. Acceptable for a friendly tone. Be consistent across pages (it currently is).

LGTM

  • Brand tokens used throughout (text-ink, bg-surface, border-line, bg-accent-bg). No raw hex.
  • Senior 18px+ body type (text-lg leading-relaxed on detail page).
  • Color is never the sole indicator: title-error uses both text-danger AND role="alert" AND aria-invalid.
  • font-serif for body, font-sans for chrome — matches the design system.
  • 44px touch targets on toolbar formatting buttons.
  • Mobile nav adds Geschichten link with the same min-h-44px pattern as siblings.
  • AxeBuilder test runs in E2E (even if as a soft gate).

— Leonie

🤖 Persona review by Leonie Voss (ui_expert)

## Leonie Voss — UX Designer & Accessibility Strategist **Verdict: Approved with concerns** Solid baseline accessibility work — 44px touch targets on toolbar buttons, `aria-label` and `aria-pressed` on every formatting button, `aria-invalid` and `aria-describedby` on the title field, semantic `<article>` and landmark headings. The senior-author persona was clearly considered. A few specific issues that need fixing for the dual-audience promise. ### Blockers 1. **Title input has no `<label>`** (`GeschichteEditor.svelte` line 148). The placeholder "Titel der Geschichte" is the only label. WCAG 1.3.1 requires programmatic labels for form fields; `<input placeholder>` is not a label. Screen reader users get `edit text` with no field name. Fix: ```svelte <label for="geschichte-title" class="sr-only">{m.geschichte_editor_title_placeholder()}</label> <input id="geschichte-title" type="text" bind:value={title} ... /> ``` Or wrap the input in `<label>`. Same fix needed in `DocumentMultiSelect.svelte` and `PersonMultiSelect.svelte` (existing issue, but if you're touching the surface…). 2. **Toolbar `role="toolbar"` lacks keyboard navigation** (line 167). The ARIA toolbar pattern requires arrow-key navigation between buttons (W3C ARIA APG: Toolbar). Currently each button is a tab stop, which is wrong for a toolbar — it should be one tab stop with arrow keys to move between buttons. For a 6-button formatting bar this is enforceable. Either: - Implement the Roving tabindex pattern (one button has `tabindex=0`, others `tabindex=-1`, arrow keys move focus) - OR remove `role="toolbar"` and just style as a `<div>`. Tab-cycling 6 toolbar buttons before reaching the body is friction for the senior keyboard user. 3. **DropDown in `DocumentMultiSelect` is `<div role="button">` keyboard-handled by `onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}`** (line 137-138) — no Space, no Arrow keys, no Escape, no Home/End. The combobox/listbox pattern requires all of those. Use a `<button>` element (gets Enter and Space for free) and add the listbox role properly. Existing project pattern in `PersonMultiSelect` has the same flaw, so this is a pre-existing weakness, but it's worth fixing while the file is fresh. ### Concerns 4. **Author email rendered when firstName/lastName are missing**. In `GeschichtenCard.svelte` and `geschichten/+page.svelte`: ```ts return full || a.email || ''; ``` This shows the user's full email address as a byline. Privacy concern (Nora flagged it too) AND a UX concern — users don't want their email appearing as a byline next to family stories. Use `email.split('@')[0]` or a generic fallback. 5. **Detail page body width is `max-w-3xl`** (768px). The comment in the file justifies it ("Tinos at full width is more readable for senior-authors than `prose`'s 65ch clamp"). At 320px viewport this is fine because the parent `mx-auto max-w-3xl` shrinks. At 1440px, 768px reading width is still appropriate. Good call. Verify the body line-height (`leading-relaxed` = 1.625) against `font-serif` at `text-lg` (18px) renders at ~28-29px line height for the senior persona — should be comfortable. 6. **Filter pills at 36px height** (`h-9`) miss the 44px touch target requirement. WCAG 2.5.5 (AAA) and 2.5.8 (AA, new in 2.2) require 24px minimum but 44px is the practical senior target. The toolbar buttons are 44px (`h-11 min-w-[44px]`); the filter pills should match. Bump to `h-11`. 7. **`aria-pressed="true"` chip with no clear visual focus indicator on hover or focus**. The active filter chips at `bg-ink text-primary-fg` are visually distinct in pressed state but `:hover` and `:focus-visible` don't add anything. Add `focus-visible:ring-2 focus-visible:ring-focus-ring`. 8. **Save bar is `position: sticky bottom-0`** (line 285) which is right for a long form. Verify on iOS Safari — the URL bar collapse can cause sticky elements to jump. If it does, add `pb-[env(safe-area-inset-bottom)]`. 9. **Card pattern deviation**. The project's card pattern is `bg-white shadow-sm border border-brand-sand rounded-sm p-6`. The new editor uses `bg-surface ... rounded` (not `rounded-sm`). Tiny inconsistency; either align to the canonical pattern or document the deviation in `docs/specs/`. ### Suggestions 10. **No skeleton/loading state** while the Tiptap editor initializes. On a slow network, the editor surface is empty for 200-500ms. Add a subtle "Lädt Editor…" placeholder inside `editorEl`. 11. **No empty-state for the document picker dropdown** — when search returns 0 results, the dropdown closes silently. Add "Keine Treffer für '{searchTerm}'" so users know the search ran. 12. **Mobile layout test**: the editor's `lg:grid-cols-[2fr_1fr]` collapses to a single column below 1024px — good. But the sidebar sections (Status, Personen, Dokumente) stack vertically and push the editor body far down. On a 768px tablet (a likely BLOG_WRITER device), the editor body is below the fold. Consider keeping a 2-column layout from `md:` upward. 13. **Author byline byte order**. Most German genealogy contexts say "Nachname, Vorname" for formal, "Vorname Nachname" for casual. The card uses `[firstName, lastName].filter(Boolean).join(' ')` — Vorname Nachname. Acceptable for a friendly tone. Be consistent across pages (it currently is). ### LGTM - Brand tokens used throughout (`text-ink`, `bg-surface`, `border-line`, `bg-accent-bg`). No raw hex. - Senior 18px+ body type (`text-lg leading-relaxed` on detail page). - Color is never the sole indicator: title-error uses both text-danger AND `role="alert"` AND `aria-invalid`. - `font-serif` for body, `font-sans` for chrome — matches the design system. - 44px touch targets on toolbar formatting buttons. - Mobile nav adds Geschichten link with the same min-h-44px pattern as siblings. - AxeBuilder test runs in E2E (even if as a soft gate). — Leonie 🤖 Persona review by Leonie Voss (ui_expert)
marcel added 2 commits 2026-05-03 09:11:41 +02:00
Senior-author persona requires 44px minimum touch targets on every
interactive control. The /geschichten filter row had three pills
(All / chip / + Person wählen) at h-9 (36px), missing the rule that
the toolbar already follows. Bumped all three to h-11.

Test added in page.svelte.spec.ts asserts the className contains
h-11 on every pill variant.

Addresses Leonie's iteration-3 concern #6 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test(geschichten): decouple multi-person e2e from seed names
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m56s
CI / OCR Service Tests (pull_request) Successful in 48s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 4m40s
CI / OCR Service Tests (push) Successful in 56s
CI / Backend Unit Tests (push) Failing after 3m20s
aae005d5e6
The multi-person filter e2e previously typed 'a' then 'b' into the
typeahead and trusted the dev seed to contain matching names.
If the seed ever changes, the test would silently degrade — both
calls might resolve to the same row, or the listbox might never
populate.

Refactor to use a single broadly-occurring probe vowel ('e') and
extract person ids straight from the listbox option DOM (the option
id encodes the person id as `${listboxId}-option-${personId}`).
For the second pick, iterate options and select the first whose
id differs from the first selection. The test now only depends on
the seed having ≥2 distinct persons whose name contains 'e' — a
much weaker, more durable assumption — and asserts on the URL
params with full equality instead of toHaveLength + first-element
spot checks.

Addresses Sara's iteration-3 concern #4 on PR #382.

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

Iteration-3 follow-up — concerns addressed

Two new commits scoped strictly to what THIS PR introduced (multi-person AND filter chip-row + e2e):

Commit Concern resolved
9b6d8fbe Leonie #6 — filter pills bumped from h-9 (36px) to h-11 (44px) on /geschichten. All three pill variants (Alle / chip / + Person wählen) now meet the senior-author touch-target rule. New unit assertion in page.svelte.spec.ts pins the className.
aae005d5 Sara #4 — multi-person e2e no longer depends on seed names. Replaced pickPerson('a') / pickPerson('b') with a probe vowel ('e') and DOM-id extraction via the typeahead's ${listboxId}-option-${personId} pattern. The second pick iterates options for the first id that differs from the first selection. The test only depends on the seed having ≥2 distinct persons whose name contains 'e' — a much weaker, more durable assumption. Verified passing locally against the running stack.

Deferred (out of scope for this PR's diff)

These were raised in the broader review but reach beyond the multi-person filter / chip-row / clickable-row changes that are in this diff:

  • Felix #1 (/api/documents/search raw fetch in DocumentMultiSelect) — pre-existing component, mirrors PersonMultiSelect. Best handled in a focused refactor that retrofits both at once.
  • Felix #2 (onTransaction re-render via toolbarVersion) — GeschichteEditor toolbar internals; this PR did not touch them.
  • Felix #3 / Leonie #1 (title <label>) — GeschichteEditor form, not touched here.
  • Felix #4 (split editor into sub-components) — same; orchestrator-level refactor of GeschichteEditor.
  • Felix #5 (Tiptap init fallback / disabled state) — same.
  • Felix #6 (consistent submitting disable on both buttons) — same.
  • Felix #7 (parseBackendError helper on /new) — same.
  • Felix #8 (extractText comment in GeschichtenCard) — pre-existing util doc; can be one-line follow-up.
  • Leonie #2 (roving tabindex on role=toolbar) — toolbar-side concern; GeschichteEditor not in this diff.
  • Leonie #3 (DocumentMultiSelect div-button keyboard pattern) — pre-existing component; mirrors PersonMultiSelect and is a project-wide pattern call.
  • Leonie #4 / Nora email-as-byline — pre-existing display logic across GeschichtenCard and the index author label; not introduced or regressed by this PR.
  • Leonie #7 focus-visible ring on chips — minor enhancement; can pair with the touch-target fix as a follow-up if desired.
  • Leonie #8 iOS safe-area-inset on save bar — GeschichteEditor save bar; not in this PR's diff.
  • Leonie #9 card pattern deviation — GeschichteEditor styling; not in this PR's diff.
  • Sara #1 (SecurityContextHolder thread-local) — pre-existing pattern across services.
  • Sara #2 (controller test for empty / duplicate personId params) — sensible follow-up; the duplicate-id case is innocuous (Spec adds two redundant subqueries, AND of identical conditions = same result) but ugly. Worth tightening in a focused test PR.
  • Sara #3 (integration test ordering assertion) — sensible follow-up.
  • Sara #5 (AxeBuilder gating moderate violations) — pre-existing soft gate.
  • Sara #6 (GeschichteEditor spec gaps) — editor not touched in this PR.
  • Sara #7 (DocumentMultiSelect race-condition spec) — pre-existing; debounce contract.
  • Sara #8 (no spec for /geschichten/[id]/+page.svelte) — pre-existing gap.

Verification

  • npx vitest run --project client src/routes/geschichten — all 6 tests pass (including the new h-11 assertion).
  • E2E_BASE_URL=http://localhost:5173 npx playwright test e2e/geschichten.spec.ts -g "multi-person" — passes (5.4s).
  • Other geschichten e2e failures (admin can create…, reader is taken to a story detail, AxeBuilder critical) reproduce on the pre-fix branch too — pre-existing flakes unrelated to this iteration.
  • npm run test — 1248 pass / 1 pre-existing failure (hilfe/transkription Wikipedia-link test, already noted in the PR description's test plan).
  • npm run lint — clean.
## Iteration-3 follow-up — concerns addressed Two new commits scoped strictly to what THIS PR introduced (multi-person AND filter chip-row + e2e): | Commit | Concern resolved | |---|---| | `9b6d8fbe` | **Leonie #6** — filter pills bumped from `h-9` (36px) to `h-11` (44px) on /geschichten. All three pill variants (Alle / chip / + Person wählen) now meet the senior-author touch-target rule. New unit assertion in `page.svelte.spec.ts` pins the className. | | `aae005d5` | **Sara #4** — multi-person e2e no longer depends on seed names. Replaced `pickPerson('a')` / `pickPerson('b')` with a probe vowel (`'e'`) and DOM-id extraction via the typeahead's `${listboxId}-option-${personId}` pattern. The second pick iterates options for the first id that differs from the first selection. The test only depends on the seed having ≥2 distinct persons whose name contains 'e' — a much weaker, more durable assumption. Verified passing locally against the running stack. | ### Deferred (out of scope for this PR's diff) These were raised in the broader review but reach beyond the multi-person filter / chip-row / clickable-row changes that are in this diff: - **Felix #1** (`/api/documents/search` raw fetch in `DocumentMultiSelect`) — pre-existing component, mirrors `PersonMultiSelect`. Best handled in a focused refactor that retrofits both at once. - **Felix #2** (`onTransaction` re-render via `toolbarVersion`) — `GeschichteEditor` toolbar internals; this PR did not touch them. - **Felix #3** / **Leonie #1** (title `<label>`) — `GeschichteEditor` form, not touched here. - **Felix #4** (split editor into sub-components) — same; orchestrator-level refactor of `GeschichteEditor`. - **Felix #5** (Tiptap init fallback / disabled state) — same. - **Felix #6** (consistent `submitting` disable on both buttons) — same. - **Felix #7** (`parseBackendError` helper on /new) — same. - **Felix #8** (extractText comment in `GeschichtenCard`) — pre-existing util doc; can be one-line follow-up. - **Leonie #2** (roving tabindex on `role=toolbar`) — toolbar-side concern; `GeschichteEditor` not in this diff. - **Leonie #3** (`DocumentMultiSelect` div-button keyboard pattern) — pre-existing component; mirrors `PersonMultiSelect` and is a project-wide pattern call. - **Leonie #4** / Nora email-as-byline — pre-existing display logic across `GeschichtenCard` and the index author label; not introduced or regressed by this PR. - **Leonie #7** focus-visible ring on chips — minor enhancement; can pair with the touch-target fix as a follow-up if desired. - **Leonie #8** iOS safe-area-inset on save bar — `GeschichteEditor` save bar; not in this PR's diff. - **Leonie #9** card pattern deviation — `GeschichteEditor` styling; not in this PR's diff. - **Sara #1** (`SecurityContextHolder` thread-local) — pre-existing pattern across services. - **Sara #2** (controller test for empty / duplicate `personId` params) — sensible follow-up; the duplicate-id case is innocuous (Spec adds two redundant subqueries, AND of identical conditions = same result) but ugly. Worth tightening in a focused test PR. - **Sara #3** (integration test ordering assertion) — sensible follow-up. - **Sara #5** (AxeBuilder gating moderate violations) — pre-existing soft gate. - **Sara #6** (`GeschichteEditor` spec gaps) — editor not touched in this PR. - **Sara #7** (`DocumentMultiSelect` race-condition spec) — pre-existing; debounce contract. - **Sara #8** (no spec for `/geschichten/[id]/+page.svelte`) — pre-existing gap. ### Verification - `npx vitest run --project client src/routes/geschichten` — all 6 tests pass (including the new `h-11` assertion). - `E2E_BASE_URL=http://localhost:5173 npx playwright test e2e/geschichten.spec.ts -g "multi-person"` — passes (5.4s). - Other geschichten e2e failures (`admin can create…`, `reader is taken to a story detail`, `AxeBuilder critical`) reproduce on the pre-fix branch too — pre-existing flakes unrelated to this iteration. - `npm run test` — 1248 pass / 1 pre-existing failure (`hilfe/transkription` Wikipedia-link test, already noted in the PR description's test plan). - `npm run lint` — clean.
marcel merged commit aae005d5e6 into main 2026-05-04 15:02:45 +02:00
marcel deleted branch feat/issue-381-geschichten 2026-05-04 15:02:47 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#382