feat(lesereisen): data model + Flyway migration — GeschichteType, JourneyItem, migrate geschichten_documents #787

Open
marcel wants to merge 80 commits from feat/issue-750-lesereisen-data-model into main
Owner

Summary

Closes #750

Introduces the foundational data model for Lesereisen (Reading Journeys):

  • GeschichteType enum (STORY / JOURNEY) — default STORY for all existing rows
  • JourneyItem entity — ordered stops in a JOURNEY, each backed by an optional Document reference and/or an editorial note; ON DELETE SET NULL on the document FK so archive deletions don't break journeys
  • V72 Flyway migration — adds type column to geschichten, creates journey_items table, migrates existing geschichten_documents rows (ordered by meta_date ASC NULLS LAST), drops old junction table
  • GeschichteSummary interface projection — replaces the old entity-based list() to avoid LazyInitializationException with open-in-view=false
  • Frontend types + routesapi.ts updated for JourneyItem / GeschichteSummary; geschichten/[id] renders document-backed items as links; document picker removed from editor (follow-on issue for full journey editor)

Test plan

  • JourneyItemIntegrationTest — 9 tests: @OrderBy, cascade delete, orphan removal, type round-trip, type default, note-only, document-backed, ON DELETE SET NULL, CHECK constraint
  • GeschichteListProjectionTest — 8 tests: interface projection fields, author nesting
  • GeschichteHttpTest — 5 HTTP-layer tests (list, get, create, update, delete)
  • Frontend component specs: GeschichteEditor.svelte.spec.ts, GeschichtenCard.svelte.spec.ts, GeschichtenCard.svelte.test.ts
  • Manually verify migration on a DB with existing geschichten_documents rows

🤖 Generated with Claude Code

## Summary Closes #750 Introduces the foundational data model for Lesereisen (Reading Journeys): - **`GeschichteType` enum** (`STORY` / `JOURNEY`) — default `STORY` for all existing rows - **`JourneyItem` entity** — ordered stops in a JOURNEY, each backed by an optional `Document` reference and/or an editorial note; `ON DELETE SET NULL` on the document FK so archive deletions don't break journeys - **V72 Flyway migration** — adds `type` column to `geschichten`, creates `journey_items` table, migrates existing `geschichten_documents` rows (ordered by `meta_date ASC NULLS LAST`), drops old junction table - **`GeschichteSummary` interface projection** — replaces the old entity-based `list()` to avoid `LazyInitializationException` with `open-in-view=false` - **Frontend types + routes** — `api.ts` updated for `JourneyItem` / `GeschichteSummary`; `geschichten/[id]` renders document-backed items as links; document picker removed from editor (follow-on issue for full journey editor) ## Test plan - [ ] `JourneyItemIntegrationTest` — 9 tests: `@OrderBy`, cascade delete, orphan removal, type round-trip, type default, note-only, document-backed, ON DELETE SET NULL, CHECK constraint - [ ] `GeschichteListProjectionTest` — 8 tests: interface projection fields, author nesting - [ ] `GeschichteHttpTest` — 5 HTTP-layer tests (list, get, create, update, delete) - [ ] Frontend component specs: `GeschichteEditor.svelte.spec.ts`, `GeschichtenCard.svelte.spec.ts`, `GeschichtenCard.svelte.test.ts` - [ ] Manually verify migration on a DB with existing `geschichten_documents` rows 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 6 commits 2026-06-08 12:47:16 +02:00
- GeschichteType enum {STORY, JOURNEY} — default STORY
- JourneyItem entity replaces geschichten_documents junction table;
  position-ordered, document_id nullable (note-only items allowed),
  CHECK(document_id IS NOT NULL OR note IS NOT NULL)
- GeschichteSummary interface projection for list() queries (avoids lazy-init)
- Geschichte entity gains `type` + `items` (LAZY, orphanRemoval, CascadeType.ALL)
  replacing the old `documents` ManyToMany bag
- GeschichteUpdateDTO: remove documentIds (replaced by JourneyItem API)
- V72 migration: adds `type` column, creates `journey_items` table with
  FK ON DELETE CASCADE (geschichte) / ON DELETE SET NULL (document),
  migrates geschichten_documents ordered by meta_date, drops junction table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GeschichteService.list() now returns List<GeschichteSummary> via JPQL
  projection query; accepts (status, personIds, limit); DRAFT clamp for
  non-BLOG_WRITE users; AND-semantics person filter with sentinel UUID guard
- GeschichteService.getById() is @Transactional(readOnly=true) and calls
  Hibernate.initialize(g.getItems()) to force-init the LAZY bag under
  open-in-view=false
- GeschichteRepository: add findSummaries() JPQL query with person subquery
- GeschichteController.list(): remove documentId param, change return type
  to List<GeschichteSummary>
- GeschichteSpecifications: remove hasDocument() and documentSubquery() —
  TODO left for lesereisen-editor follow-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New tests:
- JourneyItemIntegrationTest: @OrderBy, cascade delete, orphan removal,
  type round-trip, note-only item, document-backed item, ON DELETE SET NULL,
  CHECK constraint violation (9 tests)
- GeschichteListProjectionTest (@DataJpaTest): findSummaries status filter,
  AuthorSummary, type field, authorId filter, AND-semantics person filter (8 tests)
- GeschichteHttpTest (@SpringBootTest RANDOM_PORT): list 200, getById 200 with
  JOURNEY items, getById 404, DRAFT hidden from non-BLOG_WRITE user (5 tests)

Updated tests:
- GeschichteServiceTest: mock signature (3 args), anonymous GeschichteSummary
  stubs (Jackson can't serialize Mockito interface mocks), remove documentId cases
- GeschichteControllerTest: summaryStub() concrete anonymous impl,
  updated mock matcher to 3-arg list()
- GeschichteServiceIntegrationTest: List<GeschichteSummary> return type,
  3-arg list() calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api.ts: add GeschichteType, JourneyItem, GeschichteSummary schemas;
  remove documentId param from list endpoint; change list response to
  GeschichteSummary[]; add type + items to Geschichte; remove documents field
- GeschichteEditor: remove DocumentMultiSelect + documentIds from payload
  (journey items are managed via the future Lesereisen editor, not here)
- GET /geschichten page: remove documentId filter from server load + URL logic
- geschichten/new: remove documentId pre-population from server load
- geschichten/[id]: replace g.documents with g.items (document-backed JourneyItems)
- geschichten/new + [id]/edit: remove documentIds from submit payload type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GeschichteEditor.svelte.spec.ts: remove docFactory + initialDocuments test;
  rename documentIds test to personIds-only; add familyMember+provisional to
  personFactory (were pre-existing omissions)
- GeschichtenCard.svelte.spec.ts: add type:'STORY', replace documents:[] with
  items:[], change body null→undefined to match Geschichte schema
- GeschichtenCard.svelte.test.ts: add status/type/createdAt/updatedAt to factory;
  cast result as Geschichte to avoid spread-widening type inference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs: update ARCHITECTURE, GLOSSARY, db diagram for JourneyItem and Lesereise
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 51s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
13fa4123c1
- ARCHITECTURE.md: expand geschichte domain description — two subtypes
  (STORY/JOURNEY), JourneyItem ownership, ON DELETE SET NULL FK note
- GLOSSARY.md: add JourneyItem and Lesereise terms; update Geschichte
  entry to mention type discriminator
- db-relationships.puml: replace geschichten_documents with journey_items
  (ON DELETE CASCADE to geschichten, ON DELETE SET NULL to documents)

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

🏗️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

This is a well-structured data-model PR. The layering is correct, the Flyway migration is careful, and the LazyInitializationException problem is addressed at the right layer. Two doc-update blockers remain per the architecture review rules.


Blockers

1. db-orm.puml not updated — blocker per architecture rules

The migration drops geschichten_documents and creates journey_items. The db-relationships.puml was updated correctly. But docs/architecture/db/db-orm.puml still shows the old geschichten_documents entity (lines 373, 439-440) and has no journey_items entity with its columns (id, geschichte_id, position, document_id, note) or the new type column on geschichten. Per the architect's doc-update table: "New Flyway migration adding/removing/renaming a table or column → db-orm.puml". This is a blocker — the PR does not merge until the diagram matches the code.

2. C4 L3 backend diagram l3-backend-3g-supporting.puml not updated

GeschichteService now owns a new sub-domain (journeyitem/JourneyItemRepository) and GeschichteSummary is a new type in the geschichte package. The existing diagram describes the service as managing "stories that link persons and documents" — that description is now stale. The JourneyItem component and the GeschichteSummary projection should appear. Per the rule: "New controller or service in an existing backend domain → matching l3-backend-*.puml".


Suggestions

Sentinel UUID pattern in GeschichteService.list()

The sentinel UUID.fromString("00000000-0000-0000-0000-000000000000") is a workaround for empty-IN() invalidity. It works and is documented, but it's a leaky abstraction — the caller has to know that passing an empty list triggers a different code path than passing the sentinel. Consider extracting to a named constant PERSON_FILTER_SENTINEL with a comment explaining why, or making the JPQL branch explicit at the repository layer with a CASE WHEN :personCount = 0 THEN TRUE. The current approach is fine for now given the follow-on editor issue, but flag it for cleanup when the editor lands.

GeschichteSummary interface projection — @Schema annotations missing

GeschichteSummary is an interface projection and drives the GET /api/geschichten OpenAPI type. The generated api.ts has id: string and title: string as required (correctly), but the interface itself has no @Schema(requiredMode = REQUIRED) annotations (it's an interface, not an entity, so Springdoc infers optionality differently for nested projections). Worth verifying the generated spec matches the contract expectations — id, title, status, and type should always be present for the frontend to render correctly.

GeschichteSpecifications TODO comment

The // TODO(lesereisen-editor): restore document filter via journey_items join when editor lands comment is fine as a placeholder, but the corresponding follow-on Gitea issue number should be cited so the TODO is traceable. A naked TODO without an issue number becomes orphaned after a few sprints.

Hibernate.initialize(g.getItems()) in getById()

The pattern works and the comment explaining why is excellent. However, calling g.getItems().size() (as hinted in the entity comment) rather than Hibernate.initialize(...) would be slightly more idiomatic and avoids an explicit Hibernate API import at the service layer. Either is fine — this is a style note, not a blocker.


What's Done Well

  • The ON DELETE SET NULL FK on journey_items.document_id is exactly right for this domain: an archive deletion should not break a Lesereise, just leave a placeholder stop.
  • The chk_journey_item_not_empty CHECK constraint at the database layer is the correct place for this invariant — application code can never accidentally create an empty item.
  • Position gaps of 1000 for drag-reorder headroom is a sensible default; documented in the migration.
  • The GeschichteSummary projection replacing entity serialization for the list endpoint is the right architectural move given open-in-view: false. The PersonSummaryDTO precedent is correctly cited.
  • The V72 migration header comments (pre-requisite commands, rollback procedure, assumptions AS-001/AS-002) are exemplary. This is exactly the kind of context future operators need when something goes wrong at 2am.
  • The two ADR-level comments in Geschichte.java explaining the LAZY + Hibernate.initialize pattern are load-bearing and should stay.

Overall: fix the two diagram blockers and this is mergeable.

## 🏗️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** This is a well-structured data-model PR. The layering is correct, the Flyway migration is careful, and the LazyInitializationException problem is addressed at the right layer. Two doc-update blockers remain per the architecture review rules. --- ### Blockers **1. `db-orm.puml` not updated — blocker per architecture rules** The migration drops `geschichten_documents` and creates `journey_items`. The `db-relationships.puml` was updated correctly. But `docs/architecture/db/db-orm.puml` still shows the old `geschichten_documents` entity (lines 373, 439-440) and has no `journey_items` entity with its columns (`id`, `geschichte_id`, `position`, `document_id`, `note`) or the new `type` column on `geschichten`. Per the architect's doc-update table: *"New Flyway migration adding/removing/renaming a table or column → `db-orm.puml`"*. This is a blocker — the PR does not merge until the diagram matches the code. **2. C4 L3 backend diagram `l3-backend-3g-supporting.puml` not updated** `GeschichteService` now owns a new sub-domain (`journeyitem/JourneyItemRepository`) and `GeschichteSummary` is a new type in the `geschichte` package. The existing diagram describes the service as managing "stories that link persons and documents" — that description is now stale. The `JourneyItem` component and the `GeschichteSummary` projection should appear. Per the rule: *"New controller or service in an existing backend domain → matching `l3-backend-*.puml`"*. --- ### Suggestions **Sentinel UUID pattern in `GeschichteService.list()`** The sentinel `UUID.fromString("00000000-0000-0000-0000-000000000000")` is a workaround for empty-`IN()` invalidity. It works and is documented, but it's a leaky abstraction — the caller has to know that passing an empty list triggers a different code path than passing the sentinel. Consider extracting to a named constant `PERSON_FILTER_SENTINEL` with a comment explaining why, or making the JPQL branch explicit at the repository layer with a `CASE WHEN :personCount = 0 THEN TRUE`. The current approach is fine for now given the follow-on editor issue, but flag it for cleanup when the editor lands. **`GeschichteSummary` interface projection — `@Schema` annotations missing** `GeschichteSummary` is an interface projection and drives the `GET /api/geschichten` OpenAPI type. The generated `api.ts` has `id: string` and `title: string` as required (correctly), but the interface itself has no `@Schema(requiredMode = REQUIRED)` annotations (it's an interface, not an entity, so Springdoc infers optionality differently for nested projections). Worth verifying the generated spec matches the contract expectations — `id`, `title`, `status`, and `type` should always be present for the frontend to render correctly. **`GeschichteSpecifications` TODO comment** The `// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands` comment is fine as a placeholder, but the corresponding follow-on Gitea issue number should be cited so the TODO is traceable. A naked TODO without an issue number becomes orphaned after a few sprints. **`Hibernate.initialize(g.getItems())` in `getById()`** The pattern works and the comment explaining why is excellent. However, calling `g.getItems().size()` (as hinted in the entity comment) rather than `Hibernate.initialize(...)` would be slightly more idiomatic and avoids an explicit Hibernate API import at the service layer. Either is fine — this is a style note, not a blocker. --- ### What's Done Well - The `ON DELETE SET NULL` FK on `journey_items.document_id` is exactly right for this domain: an archive deletion should not break a Lesereise, just leave a placeholder stop. - The `chk_journey_item_not_empty` CHECK constraint at the database layer is the correct place for this invariant — application code can never accidentally create an empty item. - Position gaps of 1000 for drag-reorder headroom is a sensible default; documented in the migration. - The `GeschichteSummary` projection replacing entity serialization for the list endpoint is the right architectural move given `open-in-view: false`. The `PersonSummaryDTO` precedent is correctly cited. - The V72 migration header comments (pre-requisite commands, rollback procedure, assumptions AS-001/AS-002) are exemplary. This is exactly the kind of context future operators need when something goes wrong at 2am. - The two ADR-level comments in `Geschichte.java` explaining the LAZY + `Hibernate.initialize` pattern are load-bearing and should stay. Overall: fix the two diagram blockers and this is mergeable.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid data-model work. The TDD evidence is excellent — 9+8+5 tests covering the new entities, projection, and HTTP layer. Two issues warrant attention before merge: one is a functional bug in the reader UI, one is a missing @Transactional(readOnly = true) annotation.


Blockers

1. geschichten/[id]/+page.svelte — document-backed items display documentId UUID as link text

In frontend/src/routes/geschichten/[id]/+page.svelte, the new template renders:

{item.note ?? item.documentId}

item.note is null for document-backed items, so readers see a raw UUID like 3a7f2b12-... as the link label. The old code displayed d.title and d.documentDate. This is a regression in display quality — a follow-on issue for the full reader UI is expected, but shipping a UUID-as-link-text to readers is not acceptable even as a stub. Either display a translated fallback (m.geschichten_document_link_fallback()) or filter these items out of the section entirely until the reader UI lands. The section guard g.items.some((i) => i.documentId) already hides note-only items correctly; apply the same discipline to the label.

2. GeschichteService.getById() — missing @Transactional(readOnly = true) before the PR's own comment

Looking at the diff:

+    @Transactional(readOnly = true)
     public Geschichte getById(UUID id) {

This is actually present — good. BUT the method previously had no @Transactional at all. The rule in this codebase (and Felix's own guidelines) is: read methods are not annotated (default non-transactional is fine). The comment in the entity explicitly calls out that getById() is @Transactional(readOnly=true) and calls getItems().size() to force-init. This is the correct special case — the annotation is required here precisely because of open-in-view: false + LAZY collection. The annotation is justified and documented. No change needed, but flag for other reviewers: this is an intentional deviation from the "no @Transactional on reads" default, and the comment explains why.

Actually — this is not a blocker, just a note. Reclassifying.


Suggestions

[id]/+page.svelte — unkeyed {#each} guard

{#each g.items.filter((i) => i.documentId) as item (item.id)}

The key (item.id) is correct. Good.

GeschichteEditor.sveltedocumentIds removed cleanly

The documentIds prop removal is complete across GeschichteEditor.svelte, both new/+page.svelte and [id]/edit/+page.svelte, the DTO, and the service. No dangling references visible. Clean.

GeschichteServiceTestlist_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE test is thin

The test now only verifies that findSummaries() is called, without asserting the effectiveStatus argument is PUBLISHED. The old test had the same weakness (it asserted findAll(spec, sort) was called, not what was in the spec). This is acceptable for a unit test — the status-pinning behaviour is covered end-to-end in GeschichteServiceIntegrationTest. But the test name says "forces PUBLISHED" and the assertion doesn't prove it. Consider:

verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), any(), any(), anyLong());

This makes the test prove what its name claims.

GeschichteHttpTestsetUp() deletes all geschichten globally

geschichteRepository.deleteAll() in @BeforeEach is fine for an isolated test container, but it will silently corrupt any other tests that run in the same Spring context without @Transactional rollback. Since GeschichteHttpTest intentionally omits @Transactional (correct, per the comment explaining why), this is the right trade-off — just worth noting that test execution order matters if new tests are added to the class later.

JourneyItem.java@JsonIgnore on document field

The getDocumentId() synthetic getter correctly exposes only the UUID, and the @JsonIgnore on the document field prevents the full entity from being serialized. This is clean and prevents the circular reference / N+1 problem. The CWE-79 tripwire comment on note is a useful reminder for future renderers.

GeschichteListProjectionTest@BeforeEach does not delete persons

The test creates Person rows directly but only deletes geschichten in setUp(). Person rows accumulate across test runs in the same container session. This won't cause failures because persons are looked up by the IDs created within each test, but it's noise. Not a blocker.


What's Done Well

  • The summaryStub() anonymous class in GeschichteControllerTest with the comment "Mockito interface mocks are not serialized reliably by Jackson" is exactly the right fix for a subtle test infrastructure trap.
  • Test names across JourneyItemIntegrationTest are excellent — every name is a sentence describing a behaviour.
  • The GeschichteHttpTest is the canonical guard for LazyInitializationException — the comment explaining why @Transactional is deliberately omitted at class level is load-bearing.
  • personFactory in GeschichteEditor.svelte.spec.ts now includes familyMember: false and provisional: false — correct alignment with the current Person schema.

The UUID-as-link-text regression in [id]/+page.svelte is the only item I'd want addressed before merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid data-model work. The TDD evidence is excellent — 9+8+5 tests covering the new entities, projection, and HTTP layer. Two issues warrant attention before merge: one is a functional bug in the reader UI, one is a missing `@Transactional(readOnly = true)` annotation. --- ### Blockers **1. `geschichten/[id]/+page.svelte` — document-backed items display `documentId` UUID as link text** In `frontend/src/routes/geschichten/[id]/+page.svelte`, the new template renders: ```svelte {item.note ?? item.documentId} ``` `item.note` is `null` for document-backed items, so readers see a raw UUID like `3a7f2b12-...` as the link label. The old code displayed `d.title` and `d.documentDate`. This is a regression in display quality — a follow-on issue for the full reader UI is expected, but shipping a UUID-as-link-text to readers is not acceptable even as a stub. Either display a translated fallback (`m.geschichten_document_link_fallback()`) or filter these items out of the section entirely until the reader UI lands. The section guard `g.items.some((i) => i.documentId)` already hides note-only items correctly; apply the same discipline to the label. **2. `GeschichteService.getById()` — missing `@Transactional(readOnly = true)` before the PR's own comment** Looking at the diff: ```java + @Transactional(readOnly = true) public Geschichte getById(UUID id) { ``` This is actually present — good. BUT the method previously had no `@Transactional` at all. The rule in this codebase (and Felix's own guidelines) is: *read methods are not annotated (default non-transactional is fine)*. The comment in the entity explicitly calls out that `getById()` is `@Transactional(readOnly=true)` and calls `getItems().size()` to force-init. This is the correct special case — the annotation is required here precisely because of `open-in-view: false` + LAZY collection. The annotation is justified and documented. No change needed, but flag for other reviewers: this is an intentional deviation from the "no @Transactional on reads" default, and the comment explains why. Actually — this is not a blocker, just a note. Reclassifying. --- ### Suggestions **`[id]/+page.svelte` — unkeyed `{#each}` guard** ```svelte {#each g.items.filter((i) => i.documentId) as item (item.id)} ``` The key `(item.id)` is correct. Good. **`GeschichteEditor.svelte` — `documentIds` removed cleanly** The `documentIds` prop removal is complete across `GeschichteEditor.svelte`, both `new/+page.svelte` and `[id]/edit/+page.svelte`, the DTO, and the service. No dangling references visible. Clean. **`GeschichteServiceTest` — `list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE` test is thin** The test now only verifies that `findSummaries()` is called, without asserting the `effectiveStatus` argument is `PUBLISHED`. The old test had the same weakness (it asserted `findAll(spec, sort)` was called, not what was in the spec). This is acceptable for a unit test — the status-pinning behaviour is covered end-to-end in `GeschichteServiceIntegrationTest`. But the test name says "forces PUBLISHED" and the assertion doesn't prove it. Consider: ```java verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), any(), any(), anyLong()); ``` This makes the test prove what its name claims. **`GeschichteHttpTest` — `setUp()` deletes all geschichten globally** `geschichteRepository.deleteAll()` in `@BeforeEach` is fine for an isolated test container, but it will silently corrupt any other tests that run in the same Spring context without `@Transactional` rollback. Since `GeschichteHttpTest` intentionally omits `@Transactional` (correct, per the comment explaining why), this is the right trade-off — just worth noting that test execution order matters if new tests are added to the class later. **`JourneyItem.java` — `@JsonIgnore` on `document` field** The `getDocumentId()` synthetic getter correctly exposes only the UUID, and the `@JsonIgnore` on the `document` field prevents the full entity from being serialized. This is clean and prevents the circular reference / N+1 problem. The CWE-79 tripwire comment on `note` is a useful reminder for future renderers. **`GeschichteListProjectionTest` — `@BeforeEach` does not delete persons** The test creates `Person` rows directly but only deletes `geschichten` in `setUp()`. Person rows accumulate across test runs in the same container session. This won't cause failures because persons are looked up by the IDs created within each test, but it's noise. Not a blocker. --- ### What's Done Well - The `summaryStub()` anonymous class in `GeschichteControllerTest` with the comment "Mockito interface mocks are not serialized reliably by Jackson" is exactly the right fix for a subtle test infrastructure trap. - Test names across `JourneyItemIntegrationTest` are excellent — every name is a sentence describing a behaviour. - The `GeschichteHttpTest` is the canonical guard for `LazyInitializationException` — the comment explaining why `@Transactional` is deliberately omitted at class level is load-bearing. - `personFactory` in `GeschichteEditor.svelte.spec.ts` now includes `familyMember: false` and `provisional: false` — correct alignment with the current `Person` schema. The UUID-as-link-text regression in `[id]/+page.svelte` is the only item I'd want addressed before merge.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No security vulnerabilities found. The PR introduces a new entity, a Flyway migration, and some frontend cleanup. I've audited the input paths, the FK strategy, the permission annotations, and the note field handling. Everything checks out with one observation worth noting.


Observations (not blockers)

JourneyItem.note — CWE-79 tripwire comment is correct but incomplete

The comment in JourneyItem.java reads:

// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.

This is the right approach and the comment correctly identifies the contract. Svelte's {note} interpolation auto-escapes HTML, so the current frontend rendering is safe. The risk lives in future consumers — if the note is ever included in an email, PDF export, or RSS feed, the renderer must escape it. The comment is load-bearing; do not remove it during refactoring.

One addition worth making: the note field has no length constraint at the database layer (columnDefinition = "TEXT" with no CHECK). For a production archive, an unbounded TEXT field accepts gigabyte payloads. A CHECK (char_length(note) <= 5000) or equivalent would be consistent with how transcription_blocks handles length limits. Not a blocker for this PR, but log it as a follow-on.

GeschichteService — permission annotations on write endpoints

GeschichteController write endpoints (POST, PATCH, DELETE) carry @RequirePermission(Permission.BLOG_WRITE) / @RequirePermission(Permission.WRITE_ALL) as before. The new GET /api/geschichten returning GeschichteSummary is read-only and only accessible to authenticated users (no permitAll()). The status-clamping logic in GeschichteService.list() correctly hides DRAFT stories from users without BLOG_WRITE. This is the application-layer tenant isolation pattern in use here, and it's correctly implemented.

JPQL in GeschichteRepository.findSummaries()

All parameters are bound via @Param — no string concatenation. The IN :personIds clause uses a named parameter collection. The sentinel UUID approach for empty-IN avoidance is a known safe pattern (it's a compile-time constant, not user input). No injection surface here.

Flyway migration V72DROP TABLE geschichten_documents

The migration is irreversible after commit. The pre-migration pg_dump instruction in the header is the right mitigation. The reverse procedure is documented. From a security standpoint, there are no privilege escalation risks in the DDL — it drops a junction table and creates a new one with standard FK constraints.


What's Done Well

  • @JsonIgnore on JourneyItem.document prevents the full Document entity (including all its fields) from being inadvertently serialized into the journey item payload. Only the documentId UUID is exposed. This is the minimum-exposure principle applied correctly.
  • No new @CrossOrigin annotations or permitAll() additions.
  • No new ErrorCode values were introduced, so no i18n mapping gap is possible from this PR.
  • The GeschichteService still routes document resolution through DocumentService.getDocumentById() (reserved comment preserved), which enforces existence and scope checks. Cross-domain boundary is respected.

Clean PR from a security perspective. The note-length constraint is the one thing I'd track in the backlog.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No security vulnerabilities found. The PR introduces a new entity, a Flyway migration, and some frontend cleanup. I've audited the input paths, the FK strategy, the permission annotations, and the note field handling. Everything checks out with one observation worth noting. --- ### Observations (not blockers) **`JourneyItem.note` — CWE-79 tripwire comment is correct but incomplete** The comment in `JourneyItem.java` reads: ```java // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email // renderer MUST escape this; only Svelte {note} is auto-safe. ``` This is the right approach and the comment correctly identifies the contract. Svelte's `{note}` interpolation auto-escapes HTML, so the current frontend rendering is safe. The risk lives in future consumers — if the note is ever included in an email, PDF export, or RSS feed, the renderer must escape it. The comment is load-bearing; do not remove it during refactoring. One addition worth making: the `note` field has no length constraint at the database layer (`columnDefinition = "TEXT"` with no CHECK). For a production archive, an unbounded TEXT field accepts gigabyte payloads. A `CHECK (char_length(note) <= 5000)` or equivalent would be consistent with how `transcription_blocks` handles length limits. Not a blocker for this PR, but log it as a follow-on. **`GeschichteService` — permission annotations on write endpoints** `GeschichteController` write endpoints (`POST`, `PATCH`, `DELETE`) carry `@RequirePermission(Permission.BLOG_WRITE)` / `@RequirePermission(Permission.WRITE_ALL)` as before. The new `GET /api/geschichten` returning `GeschichteSummary` is read-only and only accessible to authenticated users (no `permitAll()`). The status-clamping logic in `GeschichteService.list()` correctly hides `DRAFT` stories from users without `BLOG_WRITE`. This is the application-layer tenant isolation pattern in use here, and it's correctly implemented. **JPQL in `GeschichteRepository.findSummaries()`** All parameters are bound via `@Param` — no string concatenation. The `IN :personIds` clause uses a named parameter collection. The sentinel UUID approach for empty-IN avoidance is a known safe pattern (it's a compile-time constant, not user input). No injection surface here. **Flyway migration `V72` — `DROP TABLE geschichten_documents`** The migration is irreversible after commit. The pre-migration `pg_dump` instruction in the header is the right mitigation. The reverse procedure is documented. From a security standpoint, there are no privilege escalation risks in the DDL — it drops a junction table and creates a new one with standard FK constraints. --- ### What's Done Well - `@JsonIgnore` on `JourneyItem.document` prevents the full `Document` entity (including all its fields) from being inadvertently serialized into the journey item payload. Only the `documentId` UUID is exposed. This is the minimum-exposure principle applied correctly. - No new `@CrossOrigin` annotations or `permitAll()` additions. - No new `ErrorCode` values were introduced, so no i18n mapping gap is possible from this PR. - The `GeschichteService` still routes document resolution through `DocumentService.getDocumentById()` (reserved comment preserved), which enforces existence and scope checks. Cross-domain boundary is respected. Clean PR from a security perspective. The note-length constraint is the one thing I'd track in the backlog.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

The test suite for this PR is one of the best I've seen in this codebase. Three layers are properly covered, the tooling choices are correct, and the coverage hits all the important behavioural boundaries. A few gaps worth tracking.


What's Covered Well

Layer separation is correct:

  • JourneyItemIntegrationTest@SpringBootTest + @Transactional + Testcontainers (real Postgres). Correct for entity/cascade/constraint testing.
  • GeschichteListProjectionTest@DataJpaTest + @AutoConfigureTestDatabase(Replace.NONE) + real Postgres. Correct for repository projection testing.
  • GeschichteHttpTest@SpringBootTest(RANDOM_PORT), no @Transactional (intentional, catches lazy-init regressions), RestTemplate. Correct for HTTP/serialization boundary testing.
  • GeschichteControllerTest@WebMvcTest slice. Correct for controller/security layer.
  • GeschichteServiceTest@ExtendWith(MockitoExtension.class). Correct for service logic unit tests.

GeschichteHttpTest — the LazyInitializationException guard

The test list_returns_200_and_does_not_500_when_stories_have_journey_items() is exactly the right regression guard. Seeding a JOURNEY with items, then hitting the list endpoint without a transaction wrapper, will catch any future code change that accidentally triggers lazy loading on the list path. This test is permanent — never @Disabled.

JourneyItemIntegrationTest — CHECK constraint test

void saving_item_with_neither_document_nor_note_violates_check_constraint()

This is the correct place for this test. H2 would silently pass it; real Postgres enforces the CHECK. Testcontainers here is not optional.

GeschichteListProjectionTest — AND-semantics person filter

The two-person AND test (both must be associated) combined with the single-person test and the no-filter test covers the full truth table for the personCount logic. Solid.


Gaps (suggestions, not blockers)

1. No test for ON DELETE SET NULL path via HTTP

JourneyItemIntegrationTest.deleting_document_sets_item_document_to_null_not_delete_item() covers this at the persistence layer — good. But there's no GeschichteHttpTest verifying that after a document is deleted, GET /api/geschichten/{id} returns the item with documentId: null rather than a 500. The persistence test proves the FK works; the HTTP test would prove the JSON serialization handles a null document reference gracefully after the transaction boundary. Low priority, but a good follow-on test.

2. GeschichteServiceTest.list_forces_PUBLISHED_status — assertion gap

As Felix noted: the test verifies findSummaries() is called but not with what status argument. Adding eq(GeschichteStatus.PUBLISHED) as the first arg to verify() would make the test prove what its name claims. This is a test quality issue, not a functional bug.

3. No test for position ordering in the list endpoint response

JourneyItemIntegrationTest.items_are_returned_in_position_order_regardless_of_insertion_order() covers the JPA @OrderBy at the persistence layer. But there's no HTTP-layer test verifying that the items array in the GET /api/geschichten/{id} JSON response is ordered by position. If someone removes the @OrderBy annotation, the persistence test still passes (items are returned in insertion order, which happens to be correct in the test), but the HTTP test would catch the ordering regression in the serialized output.

4. GeschichteHttpTestloginAsWriter() uses manual XSRF cookie construction

The XSRF token is manually constructed as a UUID and sent as both cookie and header. This is correct for Spring's double-submit CSRF protection pattern. However, if the CSRF cookie name or header name changes in a security config update, this helper breaks silently (returning an empty session string). The tests would then 403 without a clear error message. Consider adding an assertion on the login response status: assertThat(resp.getStatusCode().value()).isEqualTo(200) after postForEntity to fail fast with a meaningful message if login breaks.

5. Frontend component specs — GeschichtenCard.svelte.spec.ts

The type: 'STORY' as const addition to makeStory() is correct. No test was added for type: 'JOURNEY' rendering — presumably because the Journey reader UI is a follow-on. That's fine; just ensure the follow-on issue includes a test for type-conditional rendering when it lands.


What's Done Well

  • All integration tests use postgres:16-alpine via Testcontainers. No H2 anywhere. The bugs that matter (CHECK constraints, ON DELETE SET NULL, JPQL IN-clause edge cases) are only catchable on real Postgres.
  • @BeforeEach in JourneyItemIntegrationTest uses em.flush(); em.clear() — correct pattern for ensuring the next operation hits the database, not the first-level cache.
  • The noThrowRestTemplate() helper in GeschichteHttpTest that suppresses error-handler exceptions is the right approach for asserting on non-2xx responses without try/catch noise.
  • Test names throughout are sentence-form and describe behaviour, not implementation.

Strong test suite. The gaps are follow-on items, not merge blockers.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** The test suite for this PR is one of the best I've seen in this codebase. Three layers are properly covered, the tooling choices are correct, and the coverage hits all the important behavioural boundaries. A few gaps worth tracking. --- ### What's Covered Well **Layer separation is correct:** - `JourneyItemIntegrationTest` — `@SpringBootTest` + `@Transactional` + Testcontainers (real Postgres). Correct for entity/cascade/constraint testing. - `GeschichteListProjectionTest` — `@DataJpaTest` + `@AutoConfigureTestDatabase(Replace.NONE)` + real Postgres. Correct for repository projection testing. - `GeschichteHttpTest` — `@SpringBootTest(RANDOM_PORT)`, no `@Transactional` (intentional, catches lazy-init regressions), RestTemplate. Correct for HTTP/serialization boundary testing. - `GeschichteControllerTest` — `@WebMvcTest` slice. Correct for controller/security layer. - `GeschichteServiceTest` — `@ExtendWith(MockitoExtension.class)`. Correct for service logic unit tests. **`GeschichteHttpTest` — the LazyInitializationException guard** The test `list_returns_200_and_does_not_500_when_stories_have_journey_items()` is exactly the right regression guard. Seeding a JOURNEY with items, then hitting the list endpoint without a transaction wrapper, will catch any future code change that accidentally triggers lazy loading on the list path. This test is permanent — never `@Disabled`. **`JourneyItemIntegrationTest` — CHECK constraint test** ```java void saving_item_with_neither_document_nor_note_violates_check_constraint() ``` This is the correct place for this test. H2 would silently pass it; real Postgres enforces the CHECK. Testcontainers here is not optional. **`GeschichteListProjectionTest` — AND-semantics person filter** The two-person AND test (both must be associated) combined with the single-person test and the no-filter test covers the full truth table for the `personCount` logic. Solid. --- ### Gaps (suggestions, not blockers) **1. No test for `ON DELETE SET NULL` path via HTTP** `JourneyItemIntegrationTest.deleting_document_sets_item_document_to_null_not_delete_item()` covers this at the persistence layer — good. But there's no `GeschichteHttpTest` verifying that after a document is deleted, `GET /api/geschichten/{id}` returns the item with `documentId: null` rather than a 500. The persistence test proves the FK works; the HTTP test would prove the JSON serialization handles a null `document` reference gracefully after the transaction boundary. Low priority, but a good follow-on test. **2. `GeschichteServiceTest.list_forces_PUBLISHED_status` — assertion gap** As Felix noted: the test verifies `findSummaries()` is called but not *with what* status argument. Adding `eq(GeschichteStatus.PUBLISHED)` as the first arg to `verify()` would make the test prove what its name claims. This is a test quality issue, not a functional bug. **3. No test for `position` ordering in the list endpoint response** `JourneyItemIntegrationTest.items_are_returned_in_position_order_regardless_of_insertion_order()` covers the JPA `@OrderBy` at the persistence layer. But there's no HTTP-layer test verifying that the `items` array in the `GET /api/geschichten/{id}` JSON response is ordered by position. If someone removes the `@OrderBy` annotation, the persistence test still passes (items are returned in insertion order, which happens to be correct in the test), but the HTTP test would catch the ordering regression in the serialized output. **4. `GeschichteHttpTest` — `loginAsWriter()` uses manual XSRF cookie construction** The XSRF token is manually constructed as a UUID and sent as both cookie and header. This is correct for Spring's double-submit CSRF protection pattern. However, if the CSRF cookie name or header name changes in a security config update, this helper breaks silently (returning an empty session string). The tests would then 403 without a clear error message. Consider adding an assertion on the login response status: `assertThat(resp.getStatusCode().value()).isEqualTo(200)` after `postForEntity` to fail fast with a meaningful message if login breaks. **5. Frontend component specs — `GeschichtenCard.svelte.spec.ts`** The `type: 'STORY' as const` addition to `makeStory()` is correct. No test was added for `type: 'JOURNEY'` rendering — presumably because the Journey reader UI is a follow-on. That's fine; just ensure the follow-on issue includes a test for `type`-conditional rendering when it lands. --- ### What's Done Well - All integration tests use `postgres:16-alpine` via Testcontainers. No H2 anywhere. The bugs that matter (CHECK constraints, ON DELETE SET NULL, JPQL IN-clause edge cases) are only catchable on real Postgres. - `@BeforeEach` in `JourneyItemIntegrationTest` uses `em.flush(); em.clear()` — correct pattern for ensuring the next operation hits the database, not the first-level cache. - The `noThrowRestTemplate()` helper in `GeschichteHttpTest` that suppresses error-handler exceptions is the right approach for asserting on non-2xx responses without try/catch noise. - Test names throughout are sentence-form and describe behaviour, not implementation. Strong test suite. The gaps are follow-on items, not merge blockers.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

This is primarily a data-model PR, so the frontend changes are intentionally minimal. Most of the UI work is deferred to the follow-on reader issue. However there is one user-facing regression that needs addressing before merge, and a few observations about the stub state.


Blockers

1. geschichten/[id]/+page.svelte — raw UUID displayed as link text for document-backed items

The document section now renders:

{item.note ?? item.documentId}

For a document-backed item where item.note is null, readers see a 36-character UUID string as the clickable link label. This fails multiple usability standards simultaneously:

  • Nielsen Heuristic 2 (Match between system and real world): UUIDs are not user language. A user cannot understand what 3a7f2b12-8c4d-4e5f-a6b7-c8d9e0f1a2b3 refers to.
  • Nielsen Heuristic 4 (Consistency and standards): Every other link in the app shows a human title.
  • Accessibility (WCAG 2.4.6 Headings and Labels): Link text must describe the destination. A UUID is not descriptive.
  • Senior audience concern: The 60+ transcribers who are most likely to encounter Lesereisen content will find this incomprehensible.

Required fix: Replace {item.note ?? item.documentId} with a localized fallback. The simplest correct approach for the stub window:

{item.note ?? m.geschichten_document_link()}

Where m.geschichten_document_link() translates to something like "Brief ansehen" / "View letter" / "Ver carta" across the three locales. This degrades gracefully until the reader UI can show actual document titles.

Alternatively, hide document-backed items entirely in the stub window and only show note-only items — but that hides potentially useful navigation for readers who understand the UUIDs are links.


Suggestions

2. Document section heading — "Dokumente (JourneyItems)" comment in HTML

The template comment <!-- Dokumente (JourneyItems) --> is a developer note inside the markup. Users with browser DevTools (including screen reader users inspecting the DOM) will see this. Use a code comment format that doesn't emit to the DOM if needed, or simply remove it. In Svelte, <!-- --> HTML comments are preserved in the output.

3. Section is conditionally shown only when document-backed items exist

{#if g.items && g.items.some((i) => i.documentId)}

This means note-only items (editorial interludes) are invisible on the reader detail page. Whether this is intentional or a gap depends on the follow-on reader spec. Flag it in the follow-on issue: "note-only items are currently suppressed on the detail page."

4. No empty state for g.items

When g.type === 'JOURNEY' but g.items is empty (a journey stub with no items yet), the document section is hidden (the some((i) => i.documentId) guard returns false). For a reader expecting a journey, a silent empty state is confusing. Consider showing a brief message when g.type === 'JOURNEY' && g.items.length === 0. Again, a follow-on item — just ensure the reader spec includes this state.

5. GeschichteEditor.svelte — document picker removed cleanly

The removal of the DocumentMultiSelect section from the editor is clean — no orphaned state, no dangling references. The aside panel now only contains the person picker section. The layout change is acceptable; the person picker section was already self-contained with its own <section> border.


What's Done Well

  • The removal of initialDocuments from the editor props is a clean breaking change — the TS types catch any callers that still pass the old prop.
  • The GeschichtenCard component test updates (type: 'STORY', items: []) correctly align the test fixtures with the new schema.
  • The list page hasFilters computed value simplification (data.personFilters.length > 0 only) is correct — removing documentFilter from the filter chip logic is consistent with the feature removal.

Summary: The UUID-as-link-text issue is the only user-facing item that must be fixed before merge. Everything else is either correctly deferred or cleanly done.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** This is primarily a data-model PR, so the frontend changes are intentionally minimal. Most of the UI work is deferred to the follow-on reader issue. However there is one user-facing regression that needs addressing before merge, and a few observations about the stub state. --- ### Blockers **1. `geschichten/[id]/+page.svelte` — raw UUID displayed as link text for document-backed items** The document section now renders: ```svelte {item.note ?? item.documentId} ``` For a document-backed item where `item.note` is null, readers see a 36-character UUID string as the clickable link label. This fails multiple usability standards simultaneously: - **Nielsen Heuristic 2 (Match between system and real world):** UUIDs are not user language. A user cannot understand what `3a7f2b12-8c4d-4e5f-a6b7-c8d9e0f1a2b3` refers to. - **Nielsen Heuristic 4 (Consistency and standards):** Every other link in the app shows a human title. - **Accessibility (WCAG 2.4.6 Headings and Labels):** Link text must describe the destination. A UUID is not descriptive. - **Senior audience concern:** The 60+ transcribers who are most likely to encounter Lesereisen content will find this incomprehensible. **Required fix:** Replace `{item.note ?? item.documentId}` with a localized fallback. The simplest correct approach for the stub window: ```svelte {item.note ?? m.geschichten_document_link()} ``` Where `m.geschichten_document_link()` translates to something like "Brief ansehen" / "View letter" / "Ver carta" across the three locales. This degrades gracefully until the reader UI can show actual document titles. Alternatively, hide document-backed items entirely in the stub window and only show note-only items — but that hides potentially useful navigation for readers who understand the UUIDs are links. --- ### Suggestions **2. Document section heading — "Dokumente (JourneyItems)" comment in HTML** The template comment `<!-- Dokumente (JourneyItems) -->` is a developer note inside the markup. Users with browser DevTools (including screen reader users inspecting the DOM) will see this. Use a code comment format that doesn't emit to the DOM if needed, or simply remove it. In Svelte, `<!-- -->` HTML comments are preserved in the output. **3. Section is conditionally shown only when document-backed items exist** ```svelte {#if g.items && g.items.some((i) => i.documentId)} ``` This means note-only items (editorial interludes) are invisible on the reader detail page. Whether this is intentional or a gap depends on the follow-on reader spec. Flag it in the follow-on issue: "note-only items are currently suppressed on the detail page." **4. No empty state for `g.items`** When `g.type === 'JOURNEY'` but `g.items` is empty (a journey stub with no items yet), the document section is hidden (the `some((i) => i.documentId)` guard returns false). For a reader expecting a journey, a silent empty state is confusing. Consider showing a brief message when `g.type === 'JOURNEY' && g.items.length === 0`. Again, a follow-on item — just ensure the reader spec includes this state. **5. `GeschichteEditor.svelte` — document picker removed cleanly** The removal of the `DocumentMultiSelect` section from the editor is clean — no orphaned state, no dangling references. The aside panel now only contains the person picker section. The layout change is acceptable; the person picker section was already self-contained with its own `<section>` border. --- ### What's Done Well - The removal of `initialDocuments` from the editor props is a clean breaking change — the TS types catch any callers that still pass the old prop. - The `GeschichtenCard` component test updates (`type: 'STORY'`, `items: []`) correctly align the test fixtures with the new schema. - The list page `hasFilters` computed value simplification (`data.personFilters.length > 0` only) is correct — removing `documentFilter` from the filter chip logic is consistent with the feature removal. **Summary:** The UUID-as-link-text issue is the only user-facing item that must be fixed before merge. Everything else is either correctly deferred or cleanly done.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure, CI, or deployment changes in this PR. The migration is application-managed via Flyway — nothing to touch in the Compose file or CI pipeline. A few observations for the operator.


Observations

Migration V72 — operator runbook is embedded in the SQL file

The pre-requisite pg_dump command and the reverse procedure are documented directly in the migration header. From an operations perspective this is good practice — the runbook travels with the migration. When this runs in CI it's a no-op (no data in geschichten_documents on a fresh container). When it runs in production the operator has the commands to run before applying the migration.

One operational note: the migration header says docker exec familienarchiv-db sh -c 'pg_dump ...'. The container name familienarchiv-db should match the actual service name in the production Compose file. Worth verifying before the production run — if the name is different, the command silently fails to produce the backup.

DROP TABLE geschichten_documents is irreversible

The migration correctly documents this. The Flyway history prevents re-running it. The only risk is if V72 is applied to a production database that still has live geschichten_documents rows before a backup is taken. The header mitigation (take the dump first) is the correct procedure.

No new Docker services, ports, or volumes

This PR adds no infrastructure changes. No Compose file updates needed.

CI impact

The new integration tests (JourneyItemIntegrationTest, GeschichteListProjectionTest, GeschichteHttpTest) add real Postgres container tests. Testcontainers reuses the existing container across tests in the same JVM instance — the CI time impact should be minimal (one additional container startup amortized across the test suite). No concern here.

No new environment variables

The JourneyItem.note field has no configuration. The GeschichteType enum is application-managed. Nothing to add to .env.example or the production Compose.


What's Done Well

  • No hardcoded secrets, no exposed ports, no unpinned images — because there are no new infrastructure files.
  • The migration is versioned, numbered sequentially (V72), and has a clear descriptive name.
  • The SELECT DISTINCT guard in the migration INSERT protects against duplicate junction rows creating duplicate journey items during the data migration.

Clean from an infrastructure standpoint. The only action item is verifying the container name in the production runbook before executing in prod.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure, CI, or deployment changes in this PR. The migration is application-managed via Flyway — nothing to touch in the Compose file or CI pipeline. A few observations for the operator. --- ### Observations **Migration V72 — operator runbook is embedded in the SQL file** The pre-requisite `pg_dump` command and the reverse procedure are documented directly in the migration header. From an operations perspective this is good practice — the runbook travels with the migration. When this runs in CI it's a no-op (no data in `geschichten_documents` on a fresh container). When it runs in production the operator has the commands to run *before* applying the migration. One operational note: the migration header says `docker exec familienarchiv-db sh -c 'pg_dump ...'`. The container name `familienarchiv-db` should match the actual service name in the production Compose file. Worth verifying before the production run — if the name is different, the command silently fails to produce the backup. **`DROP TABLE geschichten_documents` is irreversible** The migration correctly documents this. The Flyway history prevents re-running it. The only risk is if V72 is applied to a production database that still has live `geschichten_documents` rows before a backup is taken. The header mitigation (take the dump first) is the correct procedure. **No new Docker services, ports, or volumes** This PR adds no infrastructure changes. No Compose file updates needed. **CI impact** The new integration tests (`JourneyItemIntegrationTest`, `GeschichteListProjectionTest`, `GeschichteHttpTest`) add real Postgres container tests. Testcontainers reuses the existing container across tests in the same JVM instance — the CI time impact should be minimal (one additional container startup amortized across the test suite). No concern here. **No new environment variables** The `JourneyItem.note` field has no configuration. The `GeschichteType` enum is application-managed. Nothing to add to `.env.example` or the production Compose. --- ### What's Done Well - No hardcoded secrets, no exposed ports, no unpinned images — because there are no new infrastructure files. - The migration is versioned, numbered sequentially (V72), and has a clear descriptive name. - The `SELECT DISTINCT` guard in the migration INSERT protects against duplicate junction rows creating duplicate journey items during the data migration. Clean from an infrastructure standpoint. The only action item is verifying the container name in the production runbook before executing in prod.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Reviewing this PR against the original issue #750 (Lesereisen data model) and the broader product context. The implementation is faithful to the data model scope. Three requirements-level concerns are worth tracking.


Concerns

1. ASSUMPTION AS-002 in the migration is a product decision, not a technical one

The migration documents:

ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window.

This is a user-facing regression for published stories with linked documents. Readers who previously saw "Brief vom 12. März 1923" as a link label now see a UUID (or, after this PR's blocker is fixed, "Brief ansehen" with no date context). This assumption was made implicitly by the developer. It should have been explicitly accepted by the product owner before the PR landed.

As a requirements observation: this is the right trade-off if the follow-on reader issue is the immediate next priority. If it isn't, the regression window extends. The acceptance criteria in issue #750 should include: "Existing STORY pages with linked documents must not degrade below a minimum readable state until the reader UI lands."

2. The documentId filter on GET /api/geschichten is removed with no migration path for callers

The PR removes ?documentId= from the list endpoint. If any external caller (bookmarked URL, email link, other page in the app) uses this query parameter, it silently stops filtering. The frontend +page.server.ts and +page.svelte are cleaned up correctly. But the removal is not flagged as a breaking API change and there's no deprecation notice. For a family-internal app this is likely fine — but worth verifying no other frontend route or component passes documentId to the geschichten list API.

3. GeschichteType has no user-facing validation or error messaging

The new type field defaults to STORY. The GeschichteUpdateDTO does not include a type field — JOURNEY type cannot be set via the API at all (no setter, no editor). This is by design (follow-on editor issue), but creates an implicit requirement gap: the only way to create a JOURNEY type today is directly in the database or via the migration. This is acceptable as a stub, but the follow-on editor issue must include:

  • A type field in GeschichteUpdateDTO
  • Input validation (only STORY and JOURNEY are valid values)
  • A conditional UI that shows the journey editor vs. the prose editor based on type

These should be captured as acceptance criteria in the follow-on issue before it's worked.


What's Well-Specified

  • The GeschichteSummary projection fields (id, title, status, type, author, publishedAt, body) match what the grid card needs to render. No over-fetching, no under-fetching.
  • The JourneyItem data model (position, document_id, note, CHECK constraint) is minimal and sufficient for the described feature. No gold-plating.
  • The GLOSSARY.md additions for Geschichte, JourneyItem, and Lesereise are accurate and use user-facing language correctly.
  • The ARCHITECTURE.md update correctly describes the new domain relationship.
  • ASSUMPTION AS-001 (ordering by meta_date as migration default) is transparent and correctly framed as "best available approximation" rather than a requirement.

Open Questions to Capture in Follow-on Issue

  • OQ-001: What label should document-backed items show in the stub window before the reader UI lands? (Currently blocked on the UUID display bug.)
  • OQ-002: Should the journey editor be a new route (/geschichten/new?type=JOURNEY) or a type selector within the existing new/edit flow?
  • OQ-003: Can a STORY be converted to a JOURNEY after creation? If yes, what happens to the items list (it would be empty)?
  • OQ-004: What happens to a JOURNEY when all its items are deleted? Should it auto-revert to STORY, or stay as an empty JOURNEY?

These don't block this PR — they belong in the follow-on editor issue spec.

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Reviewing this PR against the original issue #750 (Lesereisen data model) and the broader product context. The implementation is faithful to the data model scope. Three requirements-level concerns are worth tracking. --- ### Concerns **1. ASSUMPTION AS-002 in the migration is a product decision, not a technical one** The migration documents: > *ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window.* This is a user-facing regression for published stories with linked documents. Readers who previously saw "Brief vom 12. März 1923" as a link label now see a UUID (or, after this PR's blocker is fixed, "Brief ansehen" with no date context). This assumption was made implicitly by the developer. It should have been explicitly accepted by the product owner before the PR landed. As a requirements observation: this is the right trade-off *if* the follow-on reader issue is the immediate next priority. If it isn't, the regression window extends. The acceptance criteria in issue #750 should include: "Existing STORY pages with linked documents must not degrade below a minimum readable state until the reader UI lands." **2. The `documentId` filter on `GET /api/geschichten` is removed with no migration path for callers** The PR removes `?documentId=` from the list endpoint. If any external caller (bookmarked URL, email link, other page in the app) uses this query parameter, it silently stops filtering. The frontend `+page.server.ts` and `+page.svelte` are cleaned up correctly. But the removal is not flagged as a breaking API change and there's no deprecation notice. For a family-internal app this is likely fine — but worth verifying no other frontend route or component passes `documentId` to the geschichten list API. **3. `GeschichteType` has no user-facing validation or error messaging** The new `type` field defaults to `STORY`. The `GeschichteUpdateDTO` does not include a `type` field — JOURNEY type cannot be set via the API at all (no setter, no editor). This is by design (follow-on editor issue), but creates an implicit requirement gap: the only way to create a JOURNEY type today is directly in the database or via the migration. This is acceptable as a stub, but the follow-on editor issue must include: - A `type` field in `GeschichteUpdateDTO` - Input validation (only `STORY` and `JOURNEY` are valid values) - A conditional UI that shows the journey editor vs. the prose editor based on `type` These should be captured as acceptance criteria in the follow-on issue before it's worked. --- ### What's Well-Specified - The `GeschichteSummary` projection fields (`id`, `title`, `status`, `type`, `author`, `publishedAt`, `body`) match what the grid card needs to render. No over-fetching, no under-fetching. - The `JourneyItem` data model (`position`, `document_id`, `note`, CHECK constraint) is minimal and sufficient for the described feature. No gold-plating. - The `GLOSSARY.md` additions for `Geschichte`, `JourneyItem`, and `Lesereise` are accurate and use user-facing language correctly. - The `ARCHITECTURE.md` update correctly describes the new domain relationship. - ASSUMPTION AS-001 (ordering by `meta_date` as migration default) is transparent and correctly framed as "best available approximation" rather than a requirement. --- ### Open Questions to Capture in Follow-on Issue - OQ-001: What label should document-backed items show in the stub window before the reader UI lands? (Currently blocked on the UUID display bug.) - OQ-002: Should the journey editor be a new route (`/geschichten/new?type=JOURNEY`) or a type selector within the existing new/edit flow? - OQ-003: Can a STORY be converted to a JOURNEY after creation? If yes, what happens to the `items` list (it would be empty)? - OQ-004: What happens to a JOURNEY when all its items are deleted? Should it auto-revert to STORY, or stay as an empty JOURNEY? These don't block this PR — they belong in the follow-on editor issue spec.
marcel added 1 commit 2026-06-08 12:54:40 +02:00
fix(review): address PR #787 review blockers — db-orm diagram, C4 diagram, UUID link text
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
2c5f7ac12d
- db-orm.puml: replace geschichten_documents with journey_items, add type column to geschichten, bump schema version to V72
- l3-backend-3g-supporting.puml: update GeschichteController and GeschichteService descriptions to mention STORY/JOURNEY subtypes and JourneyItem
- geschichten/[id]/+page.svelte: replace raw UUID fallback with m.geschichten_document_link_placeholder() i18n key
- messages/{de,en,es}.json: add geschichten_document_link_placeholder translation

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

Review blockers addressed — commit 2c5f7ac1

Three blockers from the multi-persona review have been fixed:

1. db-orm.puml not updated (Markus Keller)

  • Replaced geschichten_documents entity with journey_items (with id, position, note columns)
  • Added type : VARCHAR(32) NOT NULL to the geschichten entity
  • Updated FK relationships: ON DELETE CASCADE to geschichten, ON DELETE SET NULL to documents
  • Bumped schema version comment to V72 / 2026-06-08

2. l3-backend-3g-supporting.puml stale description (Markus Keller)

  • GeschichteController description now mentions JOURNEY type and GeschichteSummary projections
  • GeschichteService description now mentions the two subtypes (STORY/JOURNEY) and ordered JourneyItem sequence

3. Raw UUID as link label in geschichten/[id]/+page.svelte (Felix Brandt, Leonie Voss)

  • Replaced {item.note ?? item.documentId} with {item.note ?? m.geschichten_document_link_placeholder()}
  • Added geschichten_document_link_placeholder to all three locale files (de: "Dokument öffnen", en: "Open document", es: "Abrir documento")
## Review blockers addressed — commit `2c5f7ac1` Three blockers from the multi-persona review have been fixed: **1. `db-orm.puml` not updated** (Markus Keller) - Replaced `geschichten_documents` entity with `journey_items` (with `id`, `position`, `note` columns) - Added `type : VARCHAR(32) NOT NULL` to the `geschichten` entity - Updated FK relationships: ON DELETE CASCADE to geschichten, ON DELETE SET NULL to documents - Bumped schema version comment to V72 / 2026-06-08 **2. `l3-backend-3g-supporting.puml` stale description** (Markus Keller) - `GeschichteController` description now mentions JOURNEY type and `GeschichteSummary` projections - `GeschichteService` description now mentions the two subtypes (STORY/JOURNEY) and ordered `JourneyItem` sequence **3. Raw UUID as link label in `geschichten/[id]/+page.svelte`** (Felix Brandt, Leonie Voss) - Replaced `{item.note ?? item.documentId}` with `{item.note ?? m.geschichten_document_link_placeholder()}` - Added `geschichten_document_link_placeholder` to all three locale files (de: "Dokument öffnen", en: "Open document", es: "Abrir documento")
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

This PR is a well-executed data model migration. The GeschichteType discriminator, JourneyItem entity, V72 Flyway migration, and GeschichteSummary projection all follow the established patterns cleanly. The decision to use ON DELETE SET NULL on the document FK rather than CASCADE is architecturally correct — it preserves journey integrity when archive documents are deleted. The comment about MultipleBagFetchException risk on Geschichte (first List collection; a second EAGER List would blow up at boot) is exactly the kind of ADR-level context that belongs in the codebase.

Blockers

1. DB diagrams updated, but CLAUDE.md domain model table is not.
The architect persona table in CLAUDE.md (## Domain Model) still shows the old Document ManyToMany on Geschichte and has no entry for JourneyItem or GeschichteType. Per my own review rule: a Flyway migration that adds a new table/FK is a blocker for the domain model table update.

Specifically, CLAUDE.md says:

| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |

…and the Geschichte row is absent from the table entirely. The journey_items table and the GeschichteType enum need entries. This is a documentation debt that will mislead the next LLM or new developer reading the file.

2. GeschichteSummary interface projection missing @Schema annotations.
The interface projection fields are returned in the list endpoint and serialized to the OpenAPI spec. Without @Schema(requiredMode = REQUIRED) on getId(), getTitle(), getStatus(), getType(), the TypeScript type generator emits these as ? optionals in GeschichteSummary — the generated api.ts confirms this (id: string is non-optional, but title: string also appears non-optional only because openapi-typescript infers it from usage, not from spec annotation). This is a latent type-safety gap.

Suggestions

S1. list() method applies limit via .stream().limit(safeLimit) in Java, not at the DB layer.
The findSummaries JPQL query fetches up to MAX_LIMIT=200 rows and then trims in Java. For current data volumes this is fine, but if the table grows the query could return 200 rows that get trimmed to 10. Consider adding LIMIT :limit to the JPQL query and passing safeLimit as a parameter to push the limit into SQL. Low priority until volume justifies it.

S2. journeyitem sub-package placement is cleangeschichte/journeyitem/ follows the feature-package pattern correctly. No boundary leaks found.

S3. V72 migration comments are excellent — the production pre-requisite pg_dump command and the rollback procedure documented inline are exactly the ADR-level operational context that saves future on-call pain. This is a model for how migrations should be documented.

S4. docs/architecture/c4/l3-backend-3g-supporting.puml was updated — confirmed. The CLAUDE.md package structure table was not. Minor omission: the journeyitem sub-package should be listed under geschichte/.

Overall: solid foundational work. Fix the CLAUDE.md domain model table and the @Schema annotations before merging.

## 🏗️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** This PR is a well-executed data model migration. The `GeschichteType` discriminator, `JourneyItem` entity, V72 Flyway migration, and `GeschichteSummary` projection all follow the established patterns cleanly. The decision to use `ON DELETE SET NULL` on the document FK rather than `CASCADE` is architecturally correct — it preserves journey integrity when archive documents are deleted. The comment about `MultipleBagFetchException` risk on `Geschichte` (first `List` collection; a second EAGER `List` would blow up at boot) is exactly the kind of ADR-level context that belongs in the codebase. ### Blockers **1. DB diagrams updated, but `CLAUDE.md` domain model table is not.** The architect persona table in `CLAUDE.md` (`## Domain Model`) still shows the old `Document` ManyToMany on `Geschichte` and has no entry for `JourneyItem` or `GeschichteType`. Per my own review rule: a Flyway migration that adds a new table/FK is a blocker for the domain model table update. Specifically, `CLAUDE.md` says: ``` | `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) | ``` …and the Geschichte row is absent from the table entirely. The `journey_items` table and the `GeschichteType` enum need entries. This is a documentation debt that will mislead the next LLM or new developer reading the file. **2. `GeschichteSummary` interface projection missing `@Schema` annotations.** The interface projection fields are returned in the list endpoint and serialized to the OpenAPI spec. Without `@Schema(requiredMode = REQUIRED)` on `getId()`, `getTitle()`, `getStatus()`, `getType()`, the TypeScript type generator emits these as `?` optionals in `GeschichteSummary` — the generated `api.ts` confirms this (`id: string` is non-optional, but `title: string` also appears non-optional only because openapi-typescript infers it from usage, not from spec annotation). This is a latent type-safety gap. ### Suggestions **S1. `list()` method applies limit via `.stream().limit(safeLimit)` in Java, not at the DB layer.** The `findSummaries` JPQL query fetches up to `MAX_LIMIT=200` rows and then trims in Java. For current data volumes this is fine, but if the table grows the query could return 200 rows that get trimmed to 10. Consider adding `LIMIT :limit` to the JPQL query and passing `safeLimit` as a parameter to push the limit into SQL. Low priority until volume justifies it. **S2. `journeyitem` sub-package placement is clean** — `geschichte/journeyitem/` follows the feature-package pattern correctly. No boundary leaks found. **S3. V72 migration comments are excellent** — the production pre-requisite `pg_dump` command and the rollback procedure documented inline are exactly the ADR-level operational context that saves future on-call pain. This is a model for how migrations should be documented. **S4. `docs/architecture/c4/l3-backend-3g-supporting.puml` was updated** — confirmed. The `CLAUDE.md` package structure table was not. Minor omission: the `journeyitem` sub-package should be listed under `geschichte/`. Overall: solid foundational work. Fix the `CLAUDE.md` domain model table and the `@Schema` annotations before merging.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Good clean work throughout. The TDD evidence is solid — JourneyItemIntegrationTest (9 tests), GeschichteListProjectionTest (8 tests), and GeschichteHttpTest (5 HTTP-layer tests) all precede the implementation in commit order and test meaningful behaviors, not just happy paths. The summaryStub() pattern in GeschichteControllerTest (concrete inner class instead of Mockito interface mock) is pragmatic and correct — Jackson can't reliably serialize Mockito proxy objects.

No Blockers

The code is clean. A few observations and suggestions below.

Suggestions

S1. {#each g.items.filter((i) => i.documentId) as item (item.id)} in geschichten/[id]/+page.svelte
Filtering inside the template expression is business logic that should be a $derived:

const documentItems = $derived(g.items?.filter((i) => i.documentId) ?? []);

Then the template reads {#each documentItems as item (item.id)}. This aligns with the "business logic in $derived, not template markup" rule and also makes the empty-state check cleaner.

S2. getDocumentId() computed getter on JourneyItem is a clever Jackson trick, but worth a note in the entity javadoc.
The comment "JPA uses field access — this getter is not persisted" is accurate and important. The @JsonIgnore on the document field + the computed getDocumentId() getter pattern is not obvious at first glance. This is already commented well, so this is just an acknowledgement — no change needed.

S3. GeschichteService.list()personCount computed from personIds could silently diverge from safePersonIds.
The sentinel logic:

Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
        ? List.of(UUID.fromString("00000000-..."))
        : personIds;
long personCount = (personIds == null) ? 0 : personIds.size();

When personIds is null, personCount=0 and safePersonIds is the sentinel — correct. When personIds is empty, personCount=0 (via .size()) but safePersonIds is the sentinel — also correct. The edge case to watch: if a caller passes List.of(), personIds.size() is 0 and the sentinel is used. This is correct. The logic is sound but the dual-condition is slightly fragile. Consider a named helper like personFilterArgs(personIds) returning a record (safeIds, count). Minor — not a blocker.

S4. GeschichteEditor.svelteinitialDocuments prop removed but documentIds also removed from the onSubmit payload type.
The type signature change is clean and the test (GeschichteEditor.svelte.spec.ts) was updated to match. Confirmed no dangling documentIds references in the page components.

S5. GeschichteControllerTestlist_returns200_forReader only asserts status 200 and $[0].title.
Missing assertion: does the response contain the new type field? The test has GeschichteType.STORY in summaryStub(), so adding .andExpect(jsonPath("$[0].type").value("STORY")) would guard against accidentally dropping the type from the serialized response.

What's done well

  • The Hibernate.initialize(g.getItems()) pattern inside @Transactional(readOnly = true) is the right solution for open-in-view: false. It's explicit, testable, and documented.
  • Removing GeschichteSpecifications.hasDocument() with a clear TODO comment is cleaner than leaving dead code.
  • The ON DELETE SET NULL test (deleting_document_sets_item_document_to_null_not_delete_item) is exactly the kind of database-constraint test that catches real production bugs.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Good clean work throughout. The TDD evidence is solid — `JourneyItemIntegrationTest` (9 tests), `GeschichteListProjectionTest` (8 tests), and `GeschichteHttpTest` (5 HTTP-layer tests) all precede the implementation in commit order and test meaningful behaviors, not just happy paths. The `summaryStub()` pattern in `GeschichteControllerTest` (concrete inner class instead of Mockito interface mock) is pragmatic and correct — Jackson can't reliably serialize Mockito proxy objects. ### No Blockers The code is clean. A few observations and suggestions below. ### Suggestions **S1. `{#each g.items.filter((i) => i.documentId) as item (item.id)}` in `geschichten/[id]/+page.svelte`** Filtering inside the template expression is business logic that should be a `$derived`: ```svelte const documentItems = $derived(g.items?.filter((i) => i.documentId) ?? []); ``` Then the template reads `{#each documentItems as item (item.id)}`. This aligns with the "business logic in `$derived`, not template markup" rule and also makes the empty-state check cleaner. **S2. `getDocumentId()` computed getter on `JourneyItem` is a clever Jackson trick, but worth a note in the entity javadoc.** The comment "JPA uses field access — this getter is not persisted" is accurate and important. The `@JsonIgnore` on the `document` field + the computed `getDocumentId()` getter pattern is not obvious at first glance. This is already commented well, so this is just an acknowledgement — no change needed. **S3. `GeschichteService.list()` — `personCount` computed from `personIds` could silently diverge from `safePersonIds`.** The sentinel logic: ```java Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty()) ? List.of(UUID.fromString("00000000-...")) : personIds; long personCount = (personIds == null) ? 0 : personIds.size(); ``` When `personIds` is `null`, `personCount=0` and `safePersonIds` is the sentinel — correct. When `personIds` is empty, `personCount=0` (via `.size()`) but `safePersonIds` is the sentinel — also correct. The edge case to watch: if a caller passes `List.of()`, `personIds.size()` is 0 and the sentinel is used. This is correct. The logic is sound but the dual-condition is slightly fragile. Consider a named helper like `personFilterArgs(personIds)` returning a record `(safeIds, count)`. Minor — not a blocker. **S4. `GeschichteEditor.svelte` — `initialDocuments` prop removed but `documentIds` also removed from the `onSubmit` payload type.** The type signature change is clean and the test (`GeschichteEditor.svelte.spec.ts`) was updated to match. Confirmed no dangling `documentIds` references in the page components. ✅ **S5. `GeschichteControllerTest` — `list_returns200_forReader` only asserts status 200 and `$[0].title`.** Missing assertion: does the response contain the new `type` field? The test has `GeschichteType.STORY` in `summaryStub()`, so adding `.andExpect(jsonPath("$[0].type").value("STORY"))` would guard against accidentally dropping the type from the serialized response. ### What's done well - The `Hibernate.initialize(g.getItems())` pattern inside `@Transactional(readOnly = true)` is the right solution for `open-in-view: false`. It's explicit, testable, and documented. - Removing `GeschichteSpecifications.hasDocument()` with a clear `TODO` comment is cleaner than leaving dead code. - The `ON DELETE SET NULL` test (`deleting_document_sets_item_document_to_null_not_delete_item`) is exactly the kind of database-constraint test that catches real production bugs.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

From an infrastructure and CI perspective, this PR is clean. The Flyway migration is the only infrastructure-touching change, and it's handled correctly. No new Docker services, no compose file changes, no action version updates needed.

No Blockers

What I checked

Flyway migration (V72): The migration runs as a standard DDL transaction — ALTER TABLE, CREATE TABLE, CREATE INDEX, INSERT INTO ... SELECT, DROP TABLE. The DROP TABLE at the end is the only irreversible step, and the migration correctly documents this with the pre-requisite pg_dump command. The migration will run cleanly in CI via Testcontainers (every @SpringBootTest runs Flyway from scratch).

No new infrastructure components: No new Docker services, no new volumes, no new environment variables. The journey_items table is owned by the existing PostgreSQL instance. Monthly bill unchanged.

Production deployment note: The V72 migration's documented pre-requisite — take a pg_dump of geschichten_documents before applying — is a manual step that needs to happen before docker-compose pull && docker-compose up -d. This is documented in the migration file itself, which is the right place for it. A production deployment checklist entry would be nice, but the inline documentation is sufficient.

No CI workflow changes needed: The Gitea Actions workflow doesn't need updating — the backend integration test suite already runs Flyway on every PR via Testcontainers. The V72 migration will be exercised on the CI run for this PR.

Suggestion

S1. Consider adding a LIMIT clause note in the migration comments. The INSERT ... SELECT in Step 4 is unbounded — for an archive with thousands of geschichten_documents rows, it runs as a single transaction. This is fine for the expected data volume (family archive), but worth a comment for future reference. The existing comment "no DDL rollback path exists after commit" implicitly covers this, but an explicit "this is safe for expected row counts ≤ 10,000" would close the loop.

That's it from my end — no infrastructure concerns with this PR.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** From an infrastructure and CI perspective, this PR is clean. The Flyway migration is the only infrastructure-touching change, and it's handled correctly. No new Docker services, no compose file changes, no action version updates needed. ### No Blockers ### What I checked **Flyway migration (V72):** The migration runs as a standard DDL transaction — `ALTER TABLE`, `CREATE TABLE`, `CREATE INDEX`, `INSERT INTO ... SELECT`, `DROP TABLE`. The `DROP TABLE` at the end is the only irreversible step, and the migration correctly documents this with the pre-requisite `pg_dump` command. The migration will run cleanly in CI via Testcontainers (every `@SpringBootTest` runs Flyway from scratch). ✅ **No new infrastructure components:** No new Docker services, no new volumes, no new environment variables. The `journey_items` table is owned by the existing PostgreSQL instance. Monthly bill unchanged. ✅ **Production deployment note:** The V72 migration's documented pre-requisite — take a `pg_dump` of `geschichten_documents` before applying — is a manual step that needs to happen before `docker-compose pull && docker-compose up -d`. This is documented in the migration file itself, which is the right place for it. A production deployment checklist entry would be nice, but the inline documentation is sufficient. **No CI workflow changes needed:** The Gitea Actions workflow doesn't need updating — the backend integration test suite already runs Flyway on every PR via Testcontainers. The V72 migration will be exercised on the CI run for this PR. ### Suggestion **S1. Consider adding a `LIMIT` clause note in the migration comments.** The `INSERT ... SELECT` in Step 4 is unbounded — for an archive with thousands of `geschichten_documents` rows, it runs as a single transaction. This is fine for the expected data volume (family archive), but worth a comment for future reference. The existing comment "no DDL rollback path exists after commit" implicitly covers this, but an explicit "this is safe for expected row counts ≤ 10,000" would close the loop. That's it from my end — no infrastructure concerns with this PR.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

This PR delivers the foundational data model for Lesereisen (issue #750) and is clearly spec-driven. The scope is well-contained: data model, migration, and frontend stub rendering. The PR description calls out ASSUMPTION AS-001 and AS-002 explicitly — this is excellent requirements practice. I'm reviewing for requirements traceability, scope completeness, and open questions that could affect the follow-on issues.

Blockers

None that would block this specific PR (it explicitly scopes itself as "foundational data model only").

Concerns (not blockers for this PR, but should be tracked as issues)

C1. Removed documentId filter from the list endpoint — no follow-on issue tracked.
The PR removes GeschichteSpecifications.hasDocument() and leaves a // TODO(lesereisen-editor): restore document filter via journey_items join when editor lands comment. This is a regression in observable API surface: callers who used ?documentId= to find stories referencing a specific document can no longer do so. The PR description doesn't mention this as a follow-on issue. There should be a Gitea issue tracking the restoration of this filter via journey_items. If the filter was only used internally (no current UI uses it), that should be confirmed — but either way, the TODO needs a ticket number.

C2. ASSUMPTION AS-002 acknowledges a reader degradation that is unquantified.
The migration note says existing published Geschichten "render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window." The PR is accepted on the basis that "the reader follow-on is the next-priority blocking dependency." This is a conscious product trade-off, but the acceptance criteria for the follow-on reader issue should include: "the document-backed item renders with the document title and date (not note ?? 'Open document')." That AC should be explicitly recorded in the follow-on issue.

C3. note field on a document-backed JourneyItem — what does it mean?
The CHECK constraint is document_id IS NOT NULL OR note IS NOT NULL. A JourneyItem can have both a document_id AND a note. In the current reader stub (geschichten/[id]/+page.svelte), document-backed items render as {item.note ?? m.geschichten_document_link_placeholder()} — meaning the note is shown as the link label instead of the document title. This conflates "editorial annotation for a document stop" with "link label text." The requirements for how note behaves on a document-backed item (annotation? label? tooltip?) need to be defined in the Lesereisen reader issue before the UI is built. Not a blocker here, but a required AC for the follow-on.

C4. No type filter on the list endpoint.
Currently GET /api/geschichten returns both STORY and JOURNEY type Geschichten mixed together. The reader UI may need to filter by type (e.g., show Lesereisen separately from Stories). This is a follow-on requirement that should be tracked.

What's well-specified

  • ASSUMPTION AS-001 (ordering heuristic) and AS-002 (degradation window) are explicitly called out with acceptance of the trade-off. This is exactly what requirements practice asks for when making assumptions.
  • The position gap strategy (multiples of 1000) is a known pattern for drag-reorder UX — good to have it codified in the schema from the start.
  • The three i18n message files (de/en/es) are all updated in lockstep.
## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** This PR delivers the foundational data model for Lesereisen (issue #750) and is clearly spec-driven. The scope is well-contained: data model, migration, and frontend stub rendering. The PR description calls out ASSUMPTION AS-001 and AS-002 explicitly — this is excellent requirements practice. I'm reviewing for requirements traceability, scope completeness, and open questions that could affect the follow-on issues. ### Blockers None that would block this specific PR (it explicitly scopes itself as "foundational data model only"). ### Concerns (not blockers for this PR, but should be tracked as issues) **C1. Removed `documentId` filter from the list endpoint — no follow-on issue tracked.** The PR removes `GeschichteSpecifications.hasDocument()` and leaves a `// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands` comment. This is a regression in observable API surface: callers who used `?documentId=` to find stories referencing a specific document can no longer do so. The PR description doesn't mention this as a follow-on issue. There should be a Gitea issue tracking the restoration of this filter via `journey_items`. If the filter was only used internally (no current UI uses it), that should be confirmed — but either way, the TODO needs a ticket number. **C2. ASSUMPTION AS-002 acknowledges a reader degradation that is unquantified.** The migration note says existing published Geschichten "render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window." The PR is accepted on the basis that "the reader follow-on is the next-priority blocking dependency." This is a conscious product trade-off, but the acceptance criteria for the follow-on reader issue should include: "the document-backed item renders with the document title and date (not `note ?? 'Open document'`)." That AC should be explicitly recorded in the follow-on issue. **C3. `note` field on a document-backed `JourneyItem` — what does it mean?** The CHECK constraint is `document_id IS NOT NULL OR note IS NOT NULL`. A `JourneyItem` can have both a `document_id` AND a `note`. In the current reader stub (`geschichten/[id]/+page.svelte`), document-backed items render as `{item.note ?? m.geschichten_document_link_placeholder()}` — meaning the `note` is shown as the link label instead of the document title. This conflates "editorial annotation for a document stop" with "link label text." The requirements for how `note` behaves on a document-backed item (annotation? label? tooltip?) need to be defined in the Lesereisen reader issue before the UI is built. Not a blocker here, but a required AC for the follow-on. **C4. No `type` filter on the list endpoint.** Currently `GET /api/geschichten` returns both `STORY` and `JOURNEY` type Geschichten mixed together. The reader UI may need to filter by type (e.g., show Lesereisen separately from Stories). This is a follow-on requirement that should be tracked. ### What's well-specified - ASSUMPTION AS-001 (ordering heuristic) and AS-002 (degradation window) are explicitly called out with acceptance of the trade-off. This is exactly what requirements practice asks for when making assumptions. - The `position` gap strategy (multiples of 1000) is a known pattern for drag-reorder UX — good to have it codified in the schema from the start. - The three i18n message files (de/en/es) are all updated in lockstep. ✅
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The PR does not introduce new attack surface — there are no new endpoints, no new user-controlled inputs to API-accessible paths, and all write paths remain behind @RequirePermission(Permission.WRITE_ALL) or @RequirePermission(Permission.BLOG_WRITE). The XSS mitigations are intact. I ran through the OWASP Top 10 relevant to this diff.

What I checked and found clean

Injection (CWE-89/JPQL injection): The new findSummaries JPQL query uses named parameters (@Param) exclusively — :effectiveStatus, :authorId, :personIds, :personCount. No string concatenation. The subquery for person count uses IN :personIds which is correctly parameterized.

Authorization boundary (BLOG_WRITE gating): getById() and list() correctly gate DRAFT visibility behind currentUserHasBlogWrite(). Critically, getById() returns NOT_FOUND (not FORBIDDEN) for DRAFTs when the caller lacks BLOG_WRITE — this prevents information leakage (DRAFT existence is not revealed).

XSS (CWE-79): The JourneyItem.note field carries a comment // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email renderer MUST escape this; only Svelte {note} is auto-safe. The frontend renders this with Svelte's {item.note} interpolation (not {@html}), which auto-escapes. This is the correct pattern. The comment documenting the trust boundary is exactly what I want to see.

Mass assignment: GeschichteUpdateDTO correctly removed documentIds — there's no path for a caller to inject document IDs into the system via the update endpoint now.

Sentinel UUID in JPQL: List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")) is passed when the personCount = 0 guard short-circuits the IN() predicate. This is not a security concern — the predicate is never evaluated when personCount = 0. The UUID value doesn't match any real entity.

One security smell (not a vulnerability)

The @JsonIgnore + computed getter pattern on JourneyItem.document has an implicit contract dependency.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id")
@JsonIgnore
private Document document;

public UUID getDocumentId() {
    return document != null ? document.getId() : null;
}

This exposes documentId to the serialized response but hides the full Document object. The security assumption is: callers only get the UUID, not the document contents. This is correct given the current getById() transaction initializes items. However, if JourneyItem is ever serialized outside a transaction (e.g., in a projection or a different endpoint), document.getId() could trigger a lazy-load outside a Hibernate session. The existing tests cover the getById() path, but this is a contract worth documenting explicitly on the getter: "Only call when a Hibernate session is active, or when document is already initialized."

This is a smell, not a vulnerability — the current code paths are safe. No fix required for this PR.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The PR does not introduce new attack surface — there are no new endpoints, no new user-controlled inputs to API-accessible paths, and all write paths remain behind `@RequirePermission(Permission.WRITE_ALL)` or `@RequirePermission(Permission.BLOG_WRITE)`. The XSS mitigations are intact. I ran through the OWASP Top 10 relevant to this diff. ### What I checked and found clean **Injection (CWE-89/JPQL injection):** The new `findSummaries` JPQL query uses named parameters (`@Param`) exclusively — `:effectiveStatus`, `:authorId`, `:personIds`, `:personCount`. No string concatenation. The subquery for person count uses `IN :personIds` which is correctly parameterized. ✅ **Authorization boundary (`BLOG_WRITE` gating):** `getById()` and `list()` correctly gate DRAFT visibility behind `currentUserHasBlogWrite()`. Critically, `getById()` returns `NOT_FOUND` (not `FORBIDDEN`) for DRAFTs when the caller lacks `BLOG_WRITE` — this prevents information leakage (DRAFT existence is not revealed). ✅ **XSS (CWE-79):** The `JourneyItem.note` field carries a comment `// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email renderer MUST escape this; only Svelte {note} is auto-safe.` The frontend renders this with Svelte's `{item.note}` interpolation (not `{@html}`), which auto-escapes. This is the correct pattern. The comment documenting the trust boundary is exactly what I want to see. ✅ **Mass assignment:** `GeschichteUpdateDTO` correctly removed `documentIds` — there's no path for a caller to inject document IDs into the system via the update endpoint now. ✅ **Sentinel UUID in JPQL:** `List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))` is passed when the `personCount = 0` guard short-circuits the `IN()` predicate. This is not a security concern — the predicate is never evaluated when `personCount = 0`. The UUID value doesn't match any real entity. ✅ ### One security smell (not a vulnerability) **The `@JsonIgnore` + computed getter pattern on `JourneyItem.document` has an implicit contract dependency.** ```java @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "document_id") @JsonIgnore private Document document; public UUID getDocumentId() { return document != null ? document.getId() : null; } ``` This exposes `documentId` to the serialized response but hides the full `Document` object. The security assumption is: callers only get the UUID, not the document contents. This is correct given the current `getById()` transaction initializes items. However, if `JourneyItem` is ever serialized outside a transaction (e.g., in a projection or a different endpoint), `document.getId()` could trigger a lazy-load outside a Hibernate session. The existing tests cover the `getById()` path, but this is a contract worth documenting explicitly on the getter: "Only call when a Hibernate session is active, or when document is already initialized." This is a smell, not a vulnerability — the current code paths are safe. No fix required for this PR.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

The test coverage for this PR is genuinely good. Three distinct test classes were written for new behavior, all using real PostgreSQL via Testcontainers, and the GeschichteHttpTest specifically avoids @Transactional at the class level to prevent masking LazyInitializationException — that is the correct and thoughtful choice. Test names are descriptive sentences. Let me walk through the layers.

No Blockers

Test pyramid assessment

Unit layer (GeschichteServiceTest): Updated correctly. The migration from findAll(Specification, Sort) to findSummaries(...) is verified with any() matchers, which is appropriate since the exact query behavior is covered at the integration layer. The new list_caps_limit_at_max_when_caller_passes_huge_value test replaces list_filters_by_documentId cleanly.

Integration layer — repository (GeschichteListProjectionTest):

  • 8 tests covering: status filter, empty result, author nesting, type field, own-draft gate, personCount=0 no-filter, single person AND, two-person AND. That's meaningful behavioral coverage, not line padding.
  • The sentinel() helper is documented with a comment explaining why a dummy UUID is used.
  • One gap: No test for findSummaries with effectiveStatus = DRAFT and authorId = null (the case where a BLOG_WRITE user requests all drafts). The hasAuthor condition in the JPQL is (:authorId IS NULL OR g.author.id = :authorId) — when authorId is null, all drafts are returned. This is the "writer sees all drafts" path and should have a test.

Integration layer — entity (JourneyItemIntegrationTest):

  • 9 tests covering: @OrderBy, cascade delete, orphan removal, type round-trip, type default, note-only, document-backed, ON DELETE SET NULL, CHECK constraint. This is comprehensive constraint coverage.
  • The @Transactional at class level with em.flush(); em.clear() between arrange and assert is the correct pattern for testing JPA ordering and lazy-loading.

HTTP layer (GeschichteHttpTest):

  • 5 tests: empty list, list with journey items (no 500), getById with items (LazyInit guard), 404 for unknown id, 404 for draft without BLOG_WRITE.
  • The explicit comment "No @Transactional at class level — that would keep a session open and mask LazyInitializationException" is exactly the reasoning I want to see documented.
  • One gap: No test for POST /api/geschichten (create) with the new API shape (no documentIds). The GeschichteControllerTest unit tests cover the shape indirectly, but an HTTP-layer create test would close the regression loop. Minor — the existing GeschichteServiceIntegrationTest likely covers creation.

Frontend tests (GeschichteEditor.svelte.spec.ts, GeschichtenCard.svelte.spec.ts, GeschichtenCard.svelte.test.ts):

  • The docFactory removal and replacement of documents: []items: [] is clean.
  • The draftFactory now includes type: 'STORY' — correct.
  • GeschichtenCard.svelte.test.ts now properly types the factory return as Geschichte (the full entity type) rather than using as unknown. This is a meaningful improvement in type safety.

Suggestions

S1. Add a GeschichteListProjectionTest case for authorId = null with effectiveStatus = DRAFT — the "BLOG_WRITE user requests all drafts, not just their own" path.

S2. The GeschichteHttpTest.list_returns_200_and_does_not_500_when_stories_have_journey_items test name is accurate but long. Consider: list_does_not_500_for_journeys_with_lazy_items. Minor style note.

S3. GeschichteControllerTest.summaryStub() returns null for getAuthor(). Since GeschichteSummary.AuthorSummary is a nested interface projection, a test where getAuthor() returns a non-null value would guard against NPEs in any future template code that accesses author.firstName without null checking.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** The test coverage for this PR is genuinely good. Three distinct test classes were written for new behavior, all using real PostgreSQL via Testcontainers, and the `GeschichteHttpTest` specifically avoids `@Transactional` at the class level to prevent masking `LazyInitializationException` — that is the correct and thoughtful choice. Test names are descriptive sentences. Let me walk through the layers. ### No Blockers ### Test pyramid assessment **Unit layer (`GeschichteServiceTest`):** Updated correctly. The migration from `findAll(Specification, Sort)` to `findSummaries(...)` is verified with `any()` matchers, which is appropriate since the exact query behavior is covered at the integration layer. The new `list_caps_limit_at_max_when_caller_passes_huge_value` test replaces `list_filters_by_documentId` cleanly. ✅ **Integration layer — repository (`GeschichteListProjectionTest`):** - 8 tests covering: status filter, empty result, author nesting, type field, own-draft gate, personCount=0 no-filter, single person AND, two-person AND. That's meaningful behavioral coverage, not line padding. - The `sentinel()` helper is documented with a comment explaining why a dummy UUID is used. ✅ - **One gap:** No test for `findSummaries` with `effectiveStatus = DRAFT` and `authorId = null` (the case where a `BLOG_WRITE` user requests all drafts). The `hasAuthor` condition in the JPQL is `(:authorId IS NULL OR g.author.id = :authorId)` — when `authorId` is null, all drafts are returned. This is the "writer sees all drafts" path and should have a test. **Integration layer — entity (`JourneyItemIntegrationTest`):** - 9 tests covering: `@OrderBy`, cascade delete, orphan removal, type round-trip, type default, note-only, document-backed, ON DELETE SET NULL, CHECK constraint. This is comprehensive constraint coverage. ✅ - The `@Transactional` at class level with `em.flush(); em.clear()` between arrange and assert is the correct pattern for testing JPA ordering and lazy-loading. ✅ **HTTP layer (`GeschichteHttpTest`):** - 5 tests: empty list, list with journey items (no 500), getById with items (LazyInit guard), 404 for unknown id, 404 for draft without BLOG_WRITE. - The explicit comment "No `@Transactional` at class level — that would keep a session open and mask `LazyInitializationException`" is exactly the reasoning I want to see documented. ✅ - **One gap:** No test for `POST /api/geschichten` (create) with the new API shape (no `documentIds`). The `GeschichteControllerTest` unit tests cover the shape indirectly, but an HTTP-layer create test would close the regression loop. Minor — the existing `GeschichteServiceIntegrationTest` likely covers creation. **Frontend tests (`GeschichteEditor.svelte.spec.ts`, `GeschichtenCard.svelte.spec.ts`, `GeschichtenCard.svelte.test.ts`):** - The `docFactory` removal and replacement of `documents: []` → `items: []` is clean. - The `draftFactory` now includes `type: 'STORY'` — correct. - `GeschichtenCard.svelte.test.ts` now properly types the factory return as `Geschichte` (the full entity type) rather than using `as unknown`. This is a meaningful improvement in type safety. ✅ ### Suggestions **S1.** Add a `GeschichteListProjectionTest` case for `authorId = null` with `effectiveStatus = DRAFT` — the "BLOG_WRITE user requests all drafts, not just their own" path. **S2.** The `GeschichteHttpTest.list_returns_200_and_does_not_500_when_stories_have_journey_items` test name is accurate but long. Consider: `list_does_not_500_for_journeys_with_lazy_items`. Minor style note. **S3.** `GeschichteControllerTest.summaryStub()` returns `null` for `getAuthor()`. Since `GeschichteSummary.AuthorSummary` is a nested interface projection, a test where `getAuthor()` returns a non-null value would guard against NPEs in any future template code that accesses `author.firstName` without null checking.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

This PR is primarily a data model and migration PR — the frontend changes are intentionally minimal stubs to unblock subsequent reader UI work. I'm reviewing the frontend changes that do ship to users now, with the understanding that the full Lesereisen reader experience is a follow-on. My concerns are scoped accordingly.

Blockers

1. Document link renders title as item.note ?? m.geschichten_document_link_placeholder() — stub text is misleading.

In geschichten/[id]/+page.svelte, document-backed journey items now render as:

<a href="/documents/{item.documentId}" ...>
    {item.note ?? m.geschichten_document_link_placeholder()}
</a>

The m.geschichten_document_link_placeholder() resolves to "Dokument öffnen" (de), "Open document" (en), "Abrir documento" (es).

For existing migrated geschichten_documents rows that had real document titles, this renders as a generic "Open document" link instead of the document title — because migrated JourneyItem rows have document_id set but note = NULL. Users who previously saw "Brief von Eugenie Raddatz, 12. März 1923" in a story now see "Open document." This is the ASSUMPTION AS-002 degradation the PR acknowledges.

However, the link label "Open document" is not actionable for a reader who doesn't know which document it leads to. Given this ships to all users immediately on merge, I'd recommend one of:

  • (a) Fetch the document title in +page.server.ts and pass it to the template (requires a follow-on API call per item — acceptable for detail page)
  • (b) Use a [Dokument {position/1000}] label as the fallback instead of "Open document" — at least communicates sequence position
  • (c) Accept the degradation and track it as a P1 in the follow-on reader issue

Option (c) is the PR's stated intent (ASSUMPTION AS-002). If that's the decision, I'd ask that the follow-on issue explicitly includes "restore document title display on journey item links" as a must-have acceptance criterion so it doesn't get deferred indefinitely.

This is a UX concern, not a code bug. Marking as a blocker because real users will see degraded content on existing stories starting from this merge — the product owner should explicitly accept this trade-off.

Suggestions

S1. The "Dokumente" section heading in the detail page is unchanged — it still says the i18n key geschichten_documents_section ("Erwähnte Dokumente" / "Referenced documents"). For a JOURNEY, these are sequenced stops, not unordered referenced documents. A follow-on might want a different heading like "Dokumente in dieser Lesereise." Not urgent, but register it for the reader issue.

S2. The filter g.items.filter((i) => i.documentId) in the template shows only document-backed items. Note-only items (editorial interludes) are invisible in the current stub. For a STORY (migrated), this is fine — there should be no note-only items. For a JOURNEY with intentional note-only items (created by the future editor), these would silently disappear from the reader. The stub is a data model PR, so this is acceptable for now — but the follow-on reader issue must render note-only items too.

S3. Accessibility on the new link rendering: The link markup:

<a href="/documents/{item.documentId}" class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted">
    {item.note ?? m.geschichten_document_link_placeholder()}
</a>

Uses {item.note ?? placeholder} as both visual label and accessible name. When item.note is null and the placeholder "Open document" is used, screen reader users hear "Open document" for every link — multiple identical accessible names on the same page (WCAG 2.4.6, also a WCAG 2.4.9 AAA issue). The follow-on reader UI must include unique accessible names per document link. For the current stub this is acceptable (same degradation acknowledged in ASSUMPTION AS-002).

What's clean

  • The Svelte template change correctly uses {#each ... as item (item.id)} with a stable key expression.
  • No inline colors or hardcoded pixel values introduced. Brand tokens used consistently.
  • i18n added in de/en/es simultaneously.
## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** This PR is primarily a data model and migration PR — the frontend changes are intentionally minimal stubs to unblock subsequent reader UI work. I'm reviewing the frontend changes that do ship to users now, with the understanding that the full Lesereisen reader experience is a follow-on. My concerns are scoped accordingly. ### Blockers **1. Document link renders title as `item.note ?? m.geschichten_document_link_placeholder()` — stub text is misleading.** In `geschichten/[id]/+page.svelte`, document-backed journey items now render as: ```svelte <a href="/documents/{item.documentId}" ...> {item.note ?? m.geschichten_document_link_placeholder()} </a> ``` The `m.geschichten_document_link_placeholder()` resolves to "Dokument öffnen" (de), "Open document" (en), "Abrir documento" (es). For existing migrated `geschichten_documents` rows that had real document titles, this renders as a generic "Open document" link instead of the document title — because migrated `JourneyItem` rows have `document_id` set but `note = NULL`. Users who previously saw "Brief von Eugenie Raddatz, 12. März 1923" in a story now see "Open document." This is the ASSUMPTION AS-002 degradation the PR acknowledges. However, the link label "Open document" is not actionable for a reader who doesn't know which document it leads to. Given this ships to all users immediately on merge, I'd recommend one of: - (a) Fetch the document title in `+page.server.ts` and pass it to the template (requires a follow-on API call per item — acceptable for detail page) - (b) Use a `[Dokument {position/1000}]` label as the fallback instead of "Open document" — at least communicates sequence position - (c) Accept the degradation and track it as a P1 in the follow-on reader issue Option (c) is the PR's stated intent (ASSUMPTION AS-002). If that's the decision, I'd ask that the follow-on issue explicitly includes "restore document title display on journey item links" as a **must-have** acceptance criterion so it doesn't get deferred indefinitely. This is a UX concern, not a code bug. Marking as a blocker because real users will see degraded content on existing stories starting from this merge — the product owner should explicitly accept this trade-off. ### Suggestions **S1. The "Dokumente" section heading in the detail page is unchanged** — it still says the i18n key `geschichten_documents_section` ("Erwähnte Dokumente" / "Referenced documents"). For a JOURNEY, these are sequenced stops, not unordered referenced documents. A follow-on might want a different heading like "Dokumente in dieser Lesereise." Not urgent, but register it for the reader issue. **S2. The filter `g.items.filter((i) => i.documentId)` in the template shows only document-backed items.** Note-only items (editorial interludes) are invisible in the current stub. For a STORY (migrated), this is fine — there should be no note-only items. For a JOURNEY with intentional note-only items (created by the future editor), these would silently disappear from the reader. The stub is a data model PR, so this is acceptable for now — but the follow-on reader issue must render note-only items too. **S3. Accessibility on the new link rendering:** The link markup: ```svelte <a href="/documents/{item.documentId}" class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"> {item.note ?? m.geschichten_document_link_placeholder()} </a> ``` Uses `{item.note ?? placeholder}` as both visual label and accessible name. When `item.note` is null and the placeholder "Open document" is used, screen reader users hear "Open document" for every link — multiple identical accessible names on the same page (WCAG 2.4.6, also a WCAG 2.4.9 AAA issue). The follow-on reader UI must include unique accessible names per document link. For the current stub this is acceptable (same degradation acknowledged in ASSUMPTION AS-002). ### What's clean - The Svelte template change correctly uses `{#each ... as item (item.id)}` with a stable key expression. ✅ - No inline colors or hardcoded pixel values introduced. Brand tokens used consistently. ✅ - i18n added in de/en/es simultaneously. ✅
marcel added 1 commit 2026-06-08 13:14:06 +02:00
fix(review): address second-pass PR #787 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
b1819867be
- CLAUDE.md: add Geschichte and JourneyItem rows to the Domain Model table
- GeschichteSummary: add @Schema(requiredMode=REQUIRED) to getId, getTitle,
  getStatus, getType, and AuthorSummary.getEmail so the TypeScript generator
  emits non-optional fields when api.ts is next regenerated

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

Second-pass review blockers addressed — commit b1819867

1. CLAUDE.md Domain Model table missing Geschichte/JourneyItem (Markus Keller)

  • Added Geschichte row: GeschichteType (STORY/JOURNEY), ManyToMany persons, OneToMany items
  • Added JourneyItem row: ManyToOne geschichte (ON DELETE CASCADE), ManyToOne document (ON DELETE SET NULL), position, optional note

2. GeschichteSummary lacking @Schema(requiredMode = REQUIRED) (Markus Keller)

  • Added annotations to getId(), getTitle(), getStatus(), getType() — all always non-null
  • Added annotation to AuthorSummary.getEmail() — email is NOT NULL in app_users
  • Left getAuthor(), getPublishedAt(), getBody() unannotated (nullable)
  • Backend compiles clean

3. Issue #786 updated (Leonie Voss)

  • Added explicit AC: "Document title (fetched from the document record — not 'Open document' placeholder)" must be shown in the reader view before the journey UX is complete
  • Added note that the current stopgap shipped in #750 is a known gap that #786 must close
## Second-pass review blockers addressed — commit `b1819867` **1. `CLAUDE.md` Domain Model table missing Geschichte/JourneyItem** (Markus Keller) - Added `Geschichte` row: `GeschichteType (STORY/JOURNEY)`, ManyToMany persons, OneToMany items - Added `JourneyItem` row: ManyToOne geschichte (ON DELETE CASCADE), ManyToOne document (ON DELETE SET NULL), position, optional note **2. `GeschichteSummary` lacking `@Schema(requiredMode = REQUIRED)`** (Markus Keller) - Added annotations to `getId()`, `getTitle()`, `getStatus()`, `getType()` — all always non-null - Added annotation to `AuthorSummary.getEmail()` — email is NOT NULL in app_users - Left `getAuthor()`, `getPublishedAt()`, `getBody()` unannotated (nullable) - Backend compiles clean **3. Issue #786 updated** (Leonie Voss) - Added explicit AC: "Document title (fetched from the document record — not 'Open document' placeholder)" must be shown in the reader view before the journey UX is complete - Added note that the current stopgap shipped in #750 is a known gap that #786 must close
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Clean foundational model. The structural choices are all defensible and the documentation was updated correctly across every doc that the architecture rules require. Let me work through my checklist.


Architecture Compliance Check

DB diagrams — required for every Flyway migration adding/removing tables:
Both docs/architecture/db/db-orm.puml and docs/architecture/db/db-relationships.puml updated. geschichten_documents replaced with journey_items, FK annotations added.

New entity/package — CLAUDE.md + C4 L3:
JourneyItem is in geschichte/journeyitem/ — a logical sub-package, not a new domain. CLAUDE.md domain model table updated with both Geschichte and JourneyItem. C4 L3 (l3-backend-3g-supporting.puml) updated for GeschichteController and GeschichteService descriptions.

New domain term — GLOSSARY.md:
JourneyItem, Lesereise, and the updated Geschichte entry are all present.

ARCHITECTURE.md — cross-domain dependencies:
geschichte domain description updated to reflect JourneyItem.document_id ON DELETE SET NULL semantics.

No new SvelteKit route added (existing routes modified). No new Docker service. No new ErrorCode or Permission.


Data Model

The ON DELETE SET NULL choice on fk_journey_items_document is architecturally correct: archive deletions should not cascade-destroy a curator's Journey. The CHECK constraint (document_id IS NOT NULL OR note IS NOT NULL) pushes integrity to the database layer — exactly what I want.

Sentinel UUID pattern in GeschichteService.list(): The 00000000-0000-0000-0000-000000000000 sentinel avoids an invalid empty IN() clause. This is a code-level workaround for a JPQL limitation. It works and is documented inline. The alternative would be a native query or a @Query with conditional logic — both are more complex. Acceptable. No blocker.

GeschichteSummary as a Spring Data interface projection avoids the LazyInitializationException on the list path cleanly. The PersonSummaryDTO precedent is correctly cited.

ASSUMPTION AS-001 (ordering by meta_date) is explicitly documented in the migration SQL with the right framing: "best available approximation, not a requirement." This is precisely what an ADR comment should say.

MultipleBagFetchException risk is correctly documented in Geschichte.java with an explicit comment. The first List on this entity is tracked; the risk is called out for future developers.


Boundary / Layering

DocumentService is retained in GeschichteService with a comment explaining why it's reserved for the lesereisen-editor follow-on. The comment explicitly notes that document resolution must go through DocumentService.getDocumentById() — not the repository directly. Boundary discipline preserved.

The new JourneyItemRepository.findAllByGeschichteId() is only used by tests. Production access to JourneyItems goes through the Geschichte entity's items collection. No service-to-repository boundary leak.


Suggestions (non-blocking)

  • TODO(lesereisen-editor) comment in GeschichteSpecifications.java — this is a good practice but it would be even better tracked as a Gitea issue reference. If the linked follow-on issue exists, add // TODO #xxx so it doesn't become orphaned tech debt.

  • Limit applied after the DB query (findSummaries(...).stream().limit(safeLimit).toList()) — the JPQL query fetches up to MAX_LIMIT (200) rows from Postgres and then trims in Java. For the current scale this is fine; at higher volumes a Pageable parameter would push the LIMIT to the database. Not a blocker for this PR.

  • ADR cross-reference — the migration comment references ADR-022 for the LAZY fetch strategy. Worth confirming that ADR exists; if it doesn't, the comment should link to what does document this decision.

Overall: clean data model PR, good documentation hygiene, appropriate database-layer integrity enforcement. The sentinel pattern and lazy-init guard are both well-commented.

Approved.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Clean foundational model. The structural choices are all defensible and the documentation was updated correctly across every doc that the architecture rules require. Let me work through my checklist. --- ### Architecture Compliance Check **DB diagrams — required for every Flyway migration adding/removing tables:** Both `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` updated. `geschichten_documents` replaced with `journey_items`, FK annotations added. ✅ **New entity/package — CLAUDE.md + C4 L3:** `JourneyItem` is in `geschichte/journeyitem/` — a logical sub-package, not a new domain. CLAUDE.md domain model table updated with both `Geschichte` and `JourneyItem`. C4 L3 (`l3-backend-3g-supporting.puml`) updated for `GeschichteController` and `GeschichteService` descriptions. ✅ **New domain term — GLOSSARY.md:** `JourneyItem`, `Lesereise`, and the updated `Geschichte` entry are all present. ✅ **ARCHITECTURE.md — cross-domain dependencies:** `geschichte` domain description updated to reflect `JourneyItem.document_id` ON DELETE SET NULL semantics. ✅ **No new SvelteKit route added** (existing routes modified). No new Docker service. No new ErrorCode or Permission. ✅ --- ### Data Model **The `ON DELETE SET NULL` choice on `fk_journey_items_document`** is architecturally correct: archive deletions should not cascade-destroy a curator's Journey. The CHECK constraint `(document_id IS NOT NULL OR note IS NOT NULL)` pushes integrity to the database layer — exactly what I want. ✅ **Sentinel UUID pattern in `GeschichteService.list()`:** The `00000000-0000-0000-0000-000000000000` sentinel avoids an invalid empty `IN()` clause. This is a code-level workaround for a JPQL limitation. It works and is documented inline. The alternative would be a native query or a `@Query` with conditional logic — both are more complex. Acceptable. No blocker. **`GeschichteSummary` as a Spring Data interface projection** avoids the `LazyInitializationException` on the list path cleanly. The `PersonSummaryDTO` precedent is correctly cited. ✅ **ASSUMPTION AS-001 (ordering by `meta_date`)** is explicitly documented in the migration SQL with the right framing: "best available approximation, not a requirement." This is precisely what an ADR comment should say. ✅ **MultipleBagFetchException risk** is correctly documented in `Geschichte.java` with an explicit comment. The first `List` on this entity is tracked; the risk is called out for future developers. ✅ --- ### Boundary / Layering `DocumentService` is retained in `GeschichteService` with a comment explaining why it's reserved for the lesereisen-editor follow-on. The comment explicitly notes that document resolution must go through `DocumentService.getDocumentById()` — not the repository directly. Boundary discipline preserved. ✅ The new `JourneyItemRepository.findAllByGeschichteId()` is only used by tests. Production access to `JourneyItem`s goes through the `Geschichte` entity's `items` collection. No service-to-repository boundary leak. ✅ --- ### Suggestions (non-blocking) - **`TODO(lesereisen-editor)` comment in `GeschichteSpecifications.java`** — this is a good practice but it would be even better tracked as a Gitea issue reference. If the linked follow-on issue exists, add `// TODO #xxx` so it doesn't become orphaned tech debt. - **Limit applied after the DB query** (`findSummaries(...).stream().limit(safeLimit).toList()`) — the JPQL query fetches up to `MAX_LIMIT` (200) rows from Postgres and then trims in Java. For the current scale this is fine; at higher volumes a `Pageable` parameter would push the `LIMIT` to the database. Not a blocker for this PR. - **ADR cross-reference** — the migration comment references `ADR-022` for the LAZY fetch strategy. Worth confirming that ADR exists; if it doesn't, the comment should link to what does document this decision. Overall: clean data model PR, good documentation hygiene, appropriate database-layer integrity enforcement. The sentinel pattern and lazy-init guard are both well-commented. **Approved.**
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid TDD execution and clean domain model. The backend is well-structured. I have one blocker on the frontend rendering logic, plus several suggestions.


Blockers

[id]/+page.svelte — document link label uses item.note as title

{item.note ?? m.geschichten_document_link_placeholder()}

item.note is the editorial annotation of a stop — it can be multi-paragraph prose. Using it as the visible link text on a document-backed item produces broken UX: a 300-word editorial note as a link label. The old code showed d.title + d.documentDate. The new code loses both.

This is a conscious stub decision (follow-on issue for the full Journey reader), but the current render is actively wrong for the document-backed case: a JourneyItem with documentId AND note will display the note as the clickable label instead of the document title, which the frontend no longer has access to (only documentId is in the API response).

The fix options:

  1. Show m.geschichten_document_link_placeholder() for all document-backed items in the stub window (remove item.note ??), and render the note separately as a non-link annotation.
  2. Accept the stub limitation but document it clearly with a <!-- TODO: fetch document title in follow-on --> comment so the next developer knows why it looks wrong.

This is a blocker because it actively misleads users: they see editorial prose as a clickable link label rather than a document title. Either fix the rendering or at minimum separate the note from the link label.


Code Quality Findings

JourneyItem.java@JsonIgnore + virtual getter pattern is clever but fragile

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id")
@JsonIgnore
private Document document;

public UUID getDocumentId() {
    return document != null ? document.getId() : null;
}

The comment correctly explains the intent. The risk: @JsonIgnore works here because the field is named document. If Lombok's @Data is ever replaced with a record or the field renamed, the serialization contract silently breaks. This is worth a brief test in JourneyItemIntegrationTest that checks the JSON contains documentId and NOT document.

Currently GeschichteHttpTest.getById_returns_200_with_items_and_does_not_500_open_in_view_false checks the HTTP 200 and body content but doesn't assert the JSON shape of items. Suggestion only.

GeschichteService.list() — sentinel UUID leaks a magic constant

Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
        ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
        : personIds;

The zero-UUID is repeated in GeschichteListProjectionTest.sentinel() too. Extract to a named constant:

private static final UUID PERSON_FILTER_SENTINEL = UUID.fromString("00000000-0000-0000-0000-000000000000");

This reveals intent and prevents divergence if the sentinel value ever needs to change.

GeschichteSummary.AuthorSummarygetEmail() is marked @Schema(requiredMode = REQUIRED) but firstName/lastName are not

The AuthorSummary interface exposes email as required but firstName/lastName as optional. That's consistent with AppUser having email as the mandatory field. Fine as-is, but worth a note: the frontend authorName() function silently falls back to email if both name fields are empty — this is robust.

GeschichteRepository.findSummaries()@Param naming is consistent

GeschichteControllerTest.summaryStub() comment is accurate

Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson.

Good rationale. The anonymous class is the right call for Jackson-serialized test stubs.

GeschichteEditor.svelte — document picker removal is clean

The removal of DocumentMultiSelect from the editor is intentional and well-scoped. The comment in the issue body references the follow-on issue. No dead code left behind.

Svelte {#each} keying — correct throughout

All {#each} blocks that changed use (item.id) keying.


Summary

The backend is well-structured: guard clauses, @Transactional(readOnly=true) correctly placed on getById(), DomainException usage consistent. The TDD evidence is strong — 22+ new tests across 3 test classes.

The one blocker is the item.note used as link label, which is a genuine UX regression from the old d.title rendering.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid TDD execution and clean domain model. The backend is well-structured. I have one blocker on the frontend rendering logic, plus several suggestions. --- ### Blockers **`[id]/+page.svelte` — document link label uses `item.note` as title** ```svelte {item.note ?? m.geschichten_document_link_placeholder()} ``` `item.note` is the editorial *annotation* of a stop — it can be multi-paragraph prose. Using it as the visible link text on a document-backed item produces broken UX: a 300-word editorial note as a link label. The old code showed `d.title` + `d.documentDate`. The new code loses both. This is a conscious stub decision (follow-on issue for the full Journey reader), but the current render is actively wrong for the document-backed case: a `JourneyItem` with `documentId` AND `note` will display the note as the clickable label instead of the document title, which the frontend no longer has access to (only `documentId` is in the API response). The fix options: 1. Show `m.geschichten_document_link_placeholder()` for all document-backed items in the stub window (remove `item.note ??`), and render the note separately as a non-link annotation. 2. Accept the stub limitation but document it clearly with a `<!-- TODO: fetch document title in follow-on -->` comment so the next developer knows why it looks wrong. This is a blocker because it actively misleads users: they see editorial prose as a clickable link label rather than a document title. Either fix the rendering or at minimum separate the note from the link label. --- ### Code Quality Findings **`JourneyItem.java` — `@JsonIgnore` + virtual getter pattern is clever but fragile** ```java @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "document_id") @JsonIgnore private Document document; public UUID getDocumentId() { return document != null ? document.getId() : null; } ``` The comment correctly explains the intent. The risk: `@JsonIgnore` works here because the field is named `document`. If Lombok's `@Data` is ever replaced with a `record` or the field renamed, the serialization contract silently breaks. This is worth a brief test in `JourneyItemIntegrationTest` that checks the JSON contains `documentId` and NOT `document`. Currently `GeschichteHttpTest.getById_returns_200_with_items_and_does_not_500_open_in_view_false` checks the HTTP 200 and body content but doesn't assert the JSON shape of `items`. Suggestion only. **`GeschichteService.list()` — sentinel UUID leaks a magic constant** ```java Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty()) ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")) : personIds; ``` The zero-UUID is repeated in `GeschichteListProjectionTest.sentinel()` too. Extract to a named constant: ```java private static final UUID PERSON_FILTER_SENTINEL = UUID.fromString("00000000-0000-0000-0000-000000000000"); ``` This reveals intent and prevents divergence if the sentinel value ever needs to change. **`GeschichteSummary.AuthorSummary` — `getEmail()` is marked `@Schema(requiredMode = REQUIRED)` but `firstName`/`lastName` are not** The AuthorSummary interface exposes `email` as required but `firstName`/`lastName` as optional. That's consistent with `AppUser` having email as the mandatory field. Fine as-is, but worth a note: the frontend `authorName()` function silently falls back to `email` if both name fields are empty — this is robust. ✅ **`GeschichteRepository.findSummaries()` — `@Param` naming is consistent** ✅ **`GeschichteControllerTest.summaryStub()` comment is accurate** > Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. Good rationale. The anonymous class is the right call for Jackson-serialized test stubs. ✅ **`GeschichteEditor.svelte` — document picker removal is clean** The removal of `DocumentMultiSelect` from the editor is intentional and well-scoped. The comment in the issue body references the follow-on issue. No dead code left behind. ✅ **Svelte `{#each}` keying — correct throughout** All `{#each}` blocks that changed use `(item.id)` keying. ✅ --- ### Summary The backend is well-structured: guard clauses, `@Transactional(readOnly=true)` correctly placed on `getById()`, `DomainException` usage consistent. The TDD evidence is strong — 22+ new tests across 3 test classes. The one blocker is the `item.note` used as link label, which is a genuine UX regression from the old `d.title` rendering.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No new authentication or authorization attack surface introduced. Existing permission boundaries are preserved. The note field handling deserves attention.


What I Checked

Permission gates — no regressions:
All write endpoints on GeschichteController retain @RequirePermission. The @GetMapping list endpoint still requires authentication (no permitAll() regression). The removal of documentId from list params doesn't weaken access control — it was never a security gate, just a filter.

JPQL in GeschichteRepository.findSummaries() — injection-free:
All parameters use @Param named parameters:

WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId)
AND ... p.id IN :personIds) = :personCount

No string concatenation. JPA parameterization is correct.

JourneyItem.note — CWE-79 tripwire comment is present and accurate:

// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.

The Svelte template uses {item.note} (text interpolation, not {@html}), so XSS is not possible in the current frontend render path. The comment correctly flags that other rendering contexts (PDF export, email digest, RSS feed) must escape this field explicitly. This is precisely the kind of threat-model comment I want to see.

GeschichteService.getById()@Transactional(readOnly=true) added:
The previously non-transactional read method is now properly marked. This is a correctness fix, not a security issue, but worth noting: read-only transactions prevent accidental writes during the session.

No new @CrossOrigin annotations. No actuator exposure changes. No new auth bypass paths.


Findings

Low severity / smell — GeschichteHttpTest uses hardcoded test credentials in source:

private static final String WRITER_PASSWORD = "pass!Geschichte1";

This is in a test class with @ActiveProfiles("test"), not production code. The password is never used outside the test suite and is seeded fresh in @BeforeEach. Not a real vulnerability — test credentials in test classes are acceptable practice. No action required.

Observation — the sentinel UUID 00000000-0000-0000-0000-000000000000 in GeschichteService is passed to IN :personIds but never evaluated when personCount=0:
The JPQL predicate is (:personCount = 0 OR (SELECT COUNT...) = :personCount). When personCount=0, the OR short-circuits and the IN clause is never reached. No security implication — just confirming the logic is sound.

No new file upload paths, no new S3 interactions, no new deserialization entry points.


Summary

This PR is security-clean. The note field has an appropriate threat-model comment, all database access is parameterized, permissions are preserved, and no new attack surface is introduced. The follow-on journey editor will need a careful review of any future write paths that accept JourneyItem content from the client — particularly the note field deserialization and any future reordering endpoint that accepts position values.

Approved.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No new authentication or authorization attack surface introduced. Existing permission boundaries are preserved. The `note` field handling deserves attention. --- ### What I Checked **Permission gates — no regressions:** All write endpoints on `GeschichteController` retain `@RequirePermission`. The `@GetMapping` list endpoint still requires authentication (no `permitAll()` regression). The removal of `documentId` from list params doesn't weaken access control — it was never a security gate, just a filter. ✅ **JPQL in `GeschichteRepository.findSummaries()` — injection-free:** All parameters use `@Param` named parameters: ```java WHERE g.status = :effectiveStatus AND (:authorId IS NULL OR g.author.id = :authorId) AND ... p.id IN :personIds) = :personCount ``` No string concatenation. JPA parameterization is correct. ✅ **`JourneyItem.note` — CWE-79 tripwire comment is present and accurate:** ```java // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email // renderer MUST escape this; only Svelte {note} is auto-safe. ``` The Svelte template uses `{item.note}` (text interpolation, not `{@html}`), so XSS is not possible in the current frontend render path. The comment correctly flags that other rendering contexts (PDF export, email digest, RSS feed) must escape this field explicitly. This is precisely the kind of threat-model comment I want to see. ✅ **`GeschichteService.getById()` — `@Transactional(readOnly=true)` added:** The previously non-transactional read method is now properly marked. This is a correctness fix, not a security issue, but worth noting: read-only transactions prevent accidental writes during the session. ✅ **No new `@CrossOrigin` annotations. No actuator exposure changes. No new auth bypass paths.** ✅ --- ### Findings **Low severity / smell — `GeschichteHttpTest` uses hardcoded test credentials in source:** ```java private static final String WRITER_PASSWORD = "pass!Geschichte1"; ``` This is in a test class with `@ActiveProfiles("test")`, not production code. The password is never used outside the test suite and is seeded fresh in `@BeforeEach`. Not a real vulnerability — test credentials in test classes are acceptable practice. No action required. **Observation — the sentinel UUID `00000000-0000-0000-0000-000000000000` in `GeschichteService` is passed to `IN :personIds` but never evaluated when `personCount=0`:** The JPQL predicate is `(:personCount = 0 OR (SELECT COUNT...) = :personCount)`. When `personCount=0`, the OR short-circuits and the `IN` clause is never reached. No security implication — just confirming the logic is sound. ✅ **No new file upload paths, no new S3 interactions, no new deserialization entry points.** ✅ --- ### Summary This PR is security-clean. The `note` field has an appropriate threat-model comment, all database access is parameterized, permissions are preserved, and no new attack surface is introduced. The follow-on journey editor will need a careful review of any future write paths that accept `JourneyItem` content from the client — particularly the `note` field deserialization and any future reordering endpoint that accepts `position` values. **Approved.**
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Strong test coverage for a foundational model PR. TDD evidence is visible throughout — the test files are well-organized and cover the right behaviors at the right layers. A few gaps worth noting.


Test Coverage Assessment

JourneyItemIntegrationTest — 9 tests, @SpringBootTest + @Transactional:

  • @OrderBy position ordering
  • Cascade ALL delete
  • Orphan removal
  • GeschichteType round-trip
  • GeschichteType default
  • Note-only item
  • Document-backed item
  • ON DELETE SET NULL
  • CHECK constraint violation

The CHECK constraint test is particularly important: it uses assertThatThrownBy() correctly and calls flush() to force the constraint check.

The @Transactional class annotation is appropriate here — each test rolls back, preventing cross-test contamination.

Real PostgreSQL via Testcontainers (not H2). The CHECK constraint and ON DELETE SET NULL would be invisible in H2.

GeschichteListProjectionTest — 8 tests, @DataJpaTest:

  • Published/draft filtering
  • Empty list
  • AuthorSummary nested fields
  • GeschichteType exposed
  • Own-drafts gate (authorId filter)
  • personCount=0 bypass
  • Single person AND filter
  • Two-person AND semantics

Good coverage of the projection contract. The sentinel() helper function is clearly named and its purpose is documented.

GeschichteHttpTest — 5 tests, @SpringBootTest(RANDOM_PORT) without @Transactional:

The deliberate omission of @Transactional at the class level is correctly explained in the class Javadoc:

No @Transactional at class level — that would keep a session open and mask LazyInitializationException caused by open-in-view: false.

This is the right call for this specific test class. The lazy-init guard test (getById_returns_200_with_items_and_does_not_500_open_in_view_false) is the canonical regression test for the most production-risky behavior in this PR.


Gaps (suggestions, not blockers)

Missing JSON shape assertion in GeschichteHttpTest.getById_returns_200_with_items_and_does_not_500_open_in_view_false:
The test verifies the response body contains "Prolog" and "Epilog" as strings, but doesn't assert that:

  • items[0].documentId is absent (null) for note-only items
  • items is present as a JSON array key
  • document (the full entity) is NOT present (regression guard for @JsonIgnore)

This is a suggestion — the current test catches the most important failure (no 500). Adding assertThat(response.getBody()).contains("\"items\"").doesNotContain("\"document\"") would add a cheap serialization contract guard.

No test for list() DRAFT visibility gate in GeschichteHttpTest:
The GeschichteServiceIntegrationTest covers the draft gate, but GeschichteHttpTest doesn't test that a user without BLOG_WRITE sees zero drafts via HTTP. The existing getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE covers getById but not list. Low risk given GeschichteServiceIntegrationTest covers this path.

GeschichteControllerTest — test for removed documentId query param:
The old list_filters_by_documentId test was replaced with list_caps_limit_at_max_when_caller_passes_huge_value (a net new test). This is correct behavior — the documentId filter is removed. No missing coverage.


Test Quality

Naming — all test method names read as sentences
items_are_returned_in_position_order_regardless_of_insertion_order is a good example.

em.flush() + em.clear() pattern — used consistently before re-loading from DB, preventing first-level cache hits from masking persistence failures.

BeforeEach setup — lean, uses builders, no giant setup methods.

No @Disabled tests.

GeschichteControllerTest.summaryStub() anonymous class — correct choice given Jackson doesn't reliably serialize Mockito interface proxies.


Summary

22+ tests across 3 new test classes, all at the appropriate test pyramid layers. The GeschichteHttpTest anti-@Transactional pattern is the most important correctness choice in this PR's test suite. CI time impact is bounded by the existing Testcontainers setup (shared container across classes). No blockers.

Approved.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** Strong test coverage for a foundational model PR. TDD evidence is visible throughout — the test files are well-organized and cover the right behaviors at the right layers. A few gaps worth noting. --- ### Test Coverage Assessment **`JourneyItemIntegrationTest` — 9 tests, `@SpringBootTest` + `@Transactional`:** - `@OrderBy` position ordering ✅ - Cascade ALL delete ✅ - Orphan removal ✅ - `GeschichteType` round-trip ✅ - `GeschichteType` default ✅ - Note-only item ✅ - Document-backed item ✅ - ON DELETE SET NULL ✅ - CHECK constraint violation ✅ The CHECK constraint test is particularly important: it uses `assertThatThrownBy()` correctly and calls `flush()` to force the constraint check. ✅ The `@Transactional` class annotation is appropriate here — each test rolls back, preventing cross-test contamination. ✅ Real PostgreSQL via Testcontainers (not H2). ✅ The CHECK constraint and `ON DELETE SET NULL` would be invisible in H2. **`GeschichteListProjectionTest` — 8 tests, `@DataJpaTest`:** - Published/draft filtering ✅ - Empty list ✅ - AuthorSummary nested fields ✅ - GeschichteType exposed ✅ - Own-drafts gate (authorId filter) ✅ - personCount=0 bypass ✅ - Single person AND filter ✅ - Two-person AND semantics ✅ Good coverage of the projection contract. The `sentinel()` helper function is clearly named and its purpose is documented. ✅ **`GeschichteHttpTest` — 5 tests, `@SpringBootTest(RANDOM_PORT)` without `@Transactional`:** The deliberate omission of `@Transactional` at the class level is correctly explained in the class Javadoc: > No @Transactional at class level — that would keep a session open and mask LazyInitializationException caused by open-in-view: false. This is the right call for this specific test class. The lazy-init guard test (`getById_returns_200_with_items_and_does_not_500_open_in_view_false`) is the canonical regression test for the most production-risky behavior in this PR. ✅ --- ### Gaps (suggestions, not blockers) **Missing JSON shape assertion in `GeschichteHttpTest.getById_returns_200_with_items_and_does_not_500_open_in_view_false`:** The test verifies the response body contains `"Prolog"` and `"Epilog"` as strings, but doesn't assert that: - `items[0].documentId` is absent (null) for note-only items - `items` is present as a JSON array key - `document` (the full entity) is NOT present (regression guard for `@JsonIgnore`) This is a suggestion — the current test catches the most important failure (no 500). Adding `assertThat(response.getBody()).contains("\"items\"").doesNotContain("\"document\"")` would add a cheap serialization contract guard. **No test for `list()` DRAFT visibility gate in `GeschichteHttpTest`:** The `GeschichteServiceIntegrationTest` covers the draft gate, but `GeschichteHttpTest` doesn't test that a user without `BLOG_WRITE` sees zero drafts via HTTP. The existing `getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE` covers `getById` but not `list`. Low risk given `GeschichteServiceIntegrationTest` covers this path. **`GeschichteControllerTest` — test for removed `documentId` query param:** The old `list_filters_by_documentId` test was replaced with `list_caps_limit_at_max_when_caller_passes_huge_value` (a net new test). This is correct behavior — the documentId filter is removed. No missing coverage. --- ### Test Quality **Naming — all test method names read as sentences** ✅ `items_are_returned_in_position_order_regardless_of_insertion_order` is a good example. **`em.flush()` + `em.clear()` pattern** — used consistently before re-loading from DB, preventing first-level cache hits from masking persistence failures. ✅ **`BeforeEach` setup** — lean, uses builders, no giant setup methods. ✅ **No `@Disabled` tests.** ✅ **`GeschichteControllerTest.summaryStub()` anonymous class** — correct choice given Jackson doesn't reliably serialize Mockito interface proxies. ✅ --- ### Summary 22+ tests across 3 new test classes, all at the appropriate test pyramid layers. The `GeschichteHttpTest` anti-`@Transactional` pattern is the most important correctness choice in this PR's test suite. CI time impact is bounded by the existing Testcontainers setup (shared container across classes). No blockers. **Approved.**
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Advocate

Verdict: ⚠️ Approved with concerns

The backend and data model changes are out of my scope. I'm reviewing only the frontend changes: geschichten/[id]/+page.svelte, GeschichteEditor.svelte, and the i18n strings. One concern I'd like addressed before merge.


Concern (not a hard blocker, but important for the senior audience)

Document link label regression in [id]/+page.svelte:

{item.note ?? m.geschichten_document_link_placeholder()}

The old render showed d.title (the document title) and optionally d.documentDate. Both are meaningful to a 60+ reader who recognizes letters by date and correspondent. The new render shows:

  • The editorial note as the link label (when note is present) — an editorial annotation can be a full paragraph. A 200-word paragraph is not a usable link label.
  • The placeholder "Dokument öffnen" / "Open document" (when note is absent) — this tells the user nothing about what document they're about to open.

For a senior reader navigating a Lesereise, both states are degraded from the previous experience. The note text alongside a link label would be the correct rendering: show the document title (or placeholder) as the link, then show the note as a separate element below.

I understand this is a conscious stub for the follow-on Journey reader, but users in production will see this degraded state in the interim. At minimum, show m.geschichten_document_link_placeholder() as the link text for document-backed items (never the note), and hide the notes section entirely until the Journey reader is ready.

Suggested stub rendering:

{#each g.items.filter((i) => i.documentId) as item (item.id)}
  <li>
    <a
      href="/documents/{item.documentId}"
      class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
    >
      {m.geschichten_document_link_placeholder()}
    </a>
  </li>
{/each}

What Looks Good

Svelte text interpolation for item.note — when it is eventually rendered, {item.note} is auto-escaped, so no XSS risk from the template.

GeschichteEditor.svelte — document picker removal — the section was removed cleanly without leaving an empty <aside> or orphaned heading.

i18n keys added in all three languages (de, en, es)geschichten_document_link_placeholder is present in all three message files.

Keyed {#each} with (item.id) — correct list reconciliation.

<article aria-labelledby="geschichte-title"> present in the existing template — semantic landmark structure is preserved.

Focus styles on the document link — the existing link class does not include focus-visible ring styles:

class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"

This is unchanged from the previous d.title rendering — so not a regression introduced by this PR. Worth a follow-on fix in the Journey reader implementation.


Summary

The interim rendering of document links is degraded from the previous experience, particularly for the senior audience who rely on document titles and dates to orient themselves. The note-as-label pattern is the specific issue. This is addressable with a 2-line change (use the placeholder for all document-backed links, suppress notes until the Journey reader is ready).

I'm approving with this concern flagged — the backend model is correct and the degraded state is a known stub window.

Approved with concern.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Advocate **Verdict: ⚠️ Approved with concerns** The backend and data model changes are out of my scope. I'm reviewing only the frontend changes: `geschichten/[id]/+page.svelte`, `GeschichteEditor.svelte`, and the i18n strings. One concern I'd like addressed before merge. --- ### Concern (not a hard blocker, but important for the senior audience) **Document link label regression in `[id]/+page.svelte`:** ```svelte {item.note ?? m.geschichten_document_link_placeholder()} ``` The old render showed `d.title` (the document title) and optionally `d.documentDate`. Both are meaningful to a 60+ reader who recognizes letters by date and correspondent. The new render shows: - The editorial note as the link label (when note is present) — an editorial annotation can be a full paragraph. A 200-word paragraph is not a usable link label. - The placeholder `"Dokument öffnen"` / `"Open document"` (when note is absent) — this tells the user nothing about what document they're about to open. For a senior reader navigating a Lesereise, both states are degraded from the previous experience. The note text alongside a link label would be the correct rendering: show the document title (or placeholder) as the link, then show the note as a separate element below. I understand this is a conscious stub for the follow-on Journey reader, but users in production will see this degraded state in the interim. At minimum, show `m.geschichten_document_link_placeholder()` as the link text for document-backed items (never the note), and hide the notes section entirely until the Journey reader is ready. **Suggested stub rendering:** ```svelte {#each g.items.filter((i) => i.documentId) as item (item.id)} <li> <a href="/documents/{item.documentId}" class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted" > {m.geschichten_document_link_placeholder()} </a> </li> {/each} ``` --- ### What Looks Good **Svelte text interpolation for `item.note`** — when it is eventually rendered, `{item.note}` is auto-escaped, so no XSS risk from the template. ✅ **`GeschichteEditor.svelte` — document picker removal** — the section was removed cleanly without leaving an empty `<aside>` or orphaned heading. ✅ **i18n keys added in all three languages (de, en, es)** — `geschichten_document_link_placeholder` is present in all three message files. ✅ **Keyed `{#each}` with `(item.id)`** — correct list reconciliation. ✅ **`<article aria-labelledby="geschichte-title">` present in the existing template** — semantic landmark structure is preserved. ✅ **Focus styles on the document link** — the existing link class does not include focus-visible ring styles: ```svelte class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted" ``` This is unchanged from the previous `d.title` rendering — so not a regression introduced by this PR. Worth a follow-on fix in the Journey reader implementation. --- ### Summary The interim rendering of document links is degraded from the previous experience, particularly for the senior audience who rely on document titles and dates to orient themselves. The note-as-label pattern is the specific issue. This is addressable with a 2-line change (use the placeholder for all document-backed links, suppress notes until the Journey reader is ready). I'm approving with this concern flagged — the backend model is correct and the degraded state is a known stub window. **Approved with concern.**
Author
Owner

🖥️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no Compose file, no CI workflow, no Dockerfile. My review is confined to migration safety and operational concerns around the DROP TABLE step.


Migration Safety

V72 — DROP TABLE geschichten_documents:

The migration includes a production pre-requisite comment with the exact pg_dump command needed before running it:

-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
--   --table=geschichten_documents \
--   -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'

This is good practice. A few operational notes:

  1. The dump path /tmp/pre_v72_backup_YYYYMMDD.sql writes inside the container. On docker compose down that disappears. Before running this migration in production, copy the dump out of the container:

    docker cp familienarchiv-db:/tmp/pre_v72_backup_$(date +%Y%m%d).sql ./backups/
    

    The migration comment doesn't mention this. Worth adding, or noting in the deploy runbook.

  2. The DROP TABLE is flagged as the only irreversible step. The reverse procedure is documented.

  3. geschichten_documents is likely empty in production (new feature table), but the comment correctly states the dump is valuable even for an empty table because it captures the table definition.

No multiple transactions in the migration — all DDL is in one Flyway migration, so Flyway wraps it in a single transaction. If CREATE TABLE journey_items succeeds but DROP TABLE geschichten_documents fails, Postgres rolls back the entire migration. Flyway will mark it as failed and not re-run it. This is correct behavior.

Index on journey_items (geschichte_id, position ASC) — appropriate for the primary access pattern (fetch all items for a journey, ordered by position).


CI Impact

No new infrastructure dependencies. The Testcontainers setup (postgres:16-alpine) already handles the migration test in CI — V72 will be run as part of the standard integration test suite. No CI configuration changes required.


No Concerns

No Docker Compose changes, no new services, no image tag changes, no CI action version changes, no secrets. Nothing infrastructure-related to flag.

Approved.

## 🖥️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no Compose file, no CI workflow, no Dockerfile. My review is confined to migration safety and operational concerns around the `DROP TABLE` step. --- ### Migration Safety **V72 — `DROP TABLE geschichten_documents`:** The migration includes a production pre-requisite comment with the exact `pg_dump` command needed before running it: ```sql -- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \ -- --table=geschichten_documents \ -- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql' ``` This is good practice. A few operational notes: 1. The dump path `/tmp/pre_v72_backup_YYYYMMDD.sql` writes inside the container. On `docker compose down` that disappears. **Before running this migration in production, copy the dump out of the container:** ```bash docker cp familienarchiv-db:/tmp/pre_v72_backup_$(date +%Y%m%d).sql ./backups/ ``` The migration comment doesn't mention this. Worth adding, or noting in the deploy runbook. 2. The `DROP TABLE` is flagged as the only irreversible step. The reverse procedure is documented. ✅ 3. `geschichten_documents` is likely empty in production (new feature table), but the comment correctly states the dump is valuable even for an empty table because it captures the table definition. ✅ **No multiple transactions in the migration** — all DDL is in one Flyway migration, so Flyway wraps it in a single transaction. If `CREATE TABLE journey_items` succeeds but `DROP TABLE geschichten_documents` fails, Postgres rolls back the entire migration. Flyway will mark it as failed and not re-run it. This is correct behavior. ✅ **Index on `journey_items (geschichte_id, position ASC)`** — appropriate for the primary access pattern (fetch all items for a journey, ordered by position). ✅ --- ### CI Impact No new infrastructure dependencies. The Testcontainers setup (`postgres:16-alpine`) already handles the migration test in CI — V72 will be run as part of the standard integration test suite. No CI configuration changes required. ✅ --- ### No Concerns No Docker Compose changes, no new services, no image tag changes, no CI action version changes, no secrets. Nothing infrastructure-related to flag. **Approved.**
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Reviewing from a requirements completeness perspective. The data model is well-aligned with the Lesereisen concept. I have one concern about an implicit requirement that appears in the stub window behavior, and a few open questions for the follow-on issues.


Requirements Coverage

FR covered by this PR:

  • GeschichteType discriminator (STORY / JOURNEY) — allows the system to distinguish prose stories from reading journeys
  • JourneyItem entity with ordered positions, optional document reference, optional editorial note
  • ON DELETE SET NULL semantics — a journey survives document deletions (archive integrity)
  • CHECK constraint (document_id IS NOT NULL OR note IS NOT NULL) — no empty stops
  • Migration of existing geschichten_documents rows — backward compatibility
  • List endpoint returns GeschichteSummary — no LazyInit 500

Implicit requirement gap — stub window UX:

The PR description notes this is a foundational model PR with the reader UI in a follow-on issue. However, ASSUMPTION AS-002 in the migration SQL acknowledges:

"This block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window."

This is an accepted degradation, but there is no linked follow-on issue number to anchor the commitment. From a requirements standpoint, the acceptance criteria for "the reader sees document links" is partially met (links exist) but the information richness requirement (title + date) is deferred without a tracked issue.

Recommendation: The <!-- TODO: fetch document title in follow-on --> comment or a link to the follow-on issue should be present in [id]/+page.svelte so the degraded state is not silently forgotten. This is the same concern Felix and Leonie raised from their respective perspectives.


Open Questions for Follow-on Issues

These are not blockers for this PR, but they should be captured in the follow-on Journey editor issue to prevent requirement drift:

  1. Position management: The position field uses gaps of 1000 for drag-reorder headroom. Who is responsible for assigning initial positions — the client or the server? What happens when two items end up with the same position after a concurrent edit? (The entity docs say "undefined relative order" — is that acceptable for the editor UX?)

  2. STORY vs JOURNEY rendering: The detail page ([id]/+page.svelte) currently renders all Geschichte types identically (body + persons + document-backed items). Will JOURNEY types need a distinct layout (e.g., no prose body, items as the primary content)? The type field is now available on the frontend via GeschichteSummary, but the detail page receives the full Geschichte entity which also has type — is the detail page expected to branch on type?

  3. Note-only items: The CHECK constraint allows items with note only (no document_id). Does the follow-on reader UI render note-only items as editorial interludes? The current frontend only shows document-backed items (g.items.filter((i) => i.documentId)) — note-only items are silently hidden.

  4. documentId in GeschichteSummary: The list projection deliberately omits items. When a stories-list card needs to indicate "this is a Journey with 5 stops," where does that count come from? A count field in the projection, or a follow-on endpoint?


Positive Findings

The ASSUMPTION AS-001 and AS-002 documentation in the migration SQL is exactly the kind of explicit requirements documentation I want to see alongside schema changes. The naming (JOURNEY, JourneyItem, Lesereise) is consistent across code, docs, and i18n. The Glossary entries are precise.

Approved — with the recommendation to link or create the follow-on issue before merge, so the stub window acceptance criteria are tracked.

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Reviewing from a requirements completeness perspective. The data model is well-aligned with the Lesereisen concept. I have one concern about an implicit requirement that appears in the stub window behavior, and a few open questions for the follow-on issues. --- ### Requirements Coverage **FR covered by this PR:** - `GeschichteType` discriminator (`STORY` / `JOURNEY`) — allows the system to distinguish prose stories from reading journeys ✅ - `JourneyItem` entity with ordered positions, optional document reference, optional editorial note ✅ - `ON DELETE SET NULL` semantics — a journey survives document deletions (archive integrity) ✅ - CHECK constraint `(document_id IS NOT NULL OR note IS NOT NULL)` — no empty stops ✅ - Migration of existing `geschichten_documents` rows — backward compatibility ✅ - List endpoint returns `GeschichteSummary` — no LazyInit 500 ✅ **Implicit requirement gap — stub window UX:** The PR description notes this is a foundational model PR with the reader UI in a follow-on issue. However, ASSUMPTION AS-002 in the migration SQL acknowledges: > "This block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window." This is an accepted degradation, but there is no linked follow-on issue number to anchor the commitment. From a requirements standpoint, the acceptance criteria for "the reader sees document links" is partially met (links exist) but the information richness requirement (title + date) is deferred without a tracked issue. **Recommendation:** The `<!-- TODO: fetch document title in follow-on -->` comment or a link to the follow-on issue should be present in `[id]/+page.svelte` so the degraded state is not silently forgotten. This is the same concern Felix and Leonie raised from their respective perspectives. --- ### Open Questions for Follow-on Issues These are not blockers for this PR, but they should be captured in the follow-on Journey editor issue to prevent requirement drift: 1. **Position management:** The `position` field uses gaps of 1000 for drag-reorder headroom. Who is responsible for assigning initial positions — the client or the server? What happens when two items end up with the same position after a concurrent edit? (The entity docs say "undefined relative order" — is that acceptable for the editor UX?) 2. **STORY vs JOURNEY rendering:** The detail page (`[id]/+page.svelte`) currently renders all `Geschichte` types identically (body + persons + document-backed items). Will JOURNEY types need a distinct layout (e.g., no prose body, items as the primary content)? The `type` field is now available on the frontend via `GeschichteSummary`, but the detail page receives the full `Geschichte` entity which also has `type` — is the detail page expected to branch on `type`? 3. **Note-only items:** The CHECK constraint allows items with `note` only (no `document_id`). Does the follow-on reader UI render note-only items as editorial interludes? The current frontend only shows document-backed items (`g.items.filter((i) => i.documentId)`) — note-only items are silently hidden. 4. **`documentId` in `GeschichteSummary`:** The list projection deliberately omits `items`. When a stories-list card needs to indicate "this is a Journey with 5 stops," where does that count come from? A count field in the projection, or a follow-on endpoint? --- ### Positive Findings The ASSUMPTION AS-001 and AS-002 documentation in the migration SQL is exactly the kind of explicit requirements documentation I want to see alongside schema changes. The naming (`JOURNEY`, `JourneyItem`, `Lesereise`) is consistent across code, docs, and i18n. The Glossary entries are precise. ✅ **Approved — with the recommendation to link or create the follow-on issue before merge, so the stub window acceptance criteria are tracked.**
marcel added 1 commit 2026-06-08 15:39:04 +02:00
fix(review): separate note from link label in journey item stub
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
45500cc5e2
item.note is editorial prose — it must not be used as the anchor label.
Always show the i18n placeholder as the link text; render note as a
caption below the link when present.

Adds TODO(#786) comment so the stub degradation is tracked.

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

Third-pass review blocker addressed — commit 45500cc5

item.note used as anchor label for document-backed JourneyItems (Felix, Leonie, Elicit)

item.note is editorial prose, not a document identifier. Using it as the <a> label meant a multi-paragraph note could render as the clickable link text.

Fix: always use m.geschichten_document_link_placeholder() as the link label; render item.note as a <p class="text-sm text-ink-3"> caption below the link when present. Added <!-- TODO(#786) --> so the stub is tracked back to the follow-on reader issue.

## Third-pass review blocker addressed — commit `45500cc5` **`item.note` used as anchor label for document-backed JourneyItems** (Felix, Leonie, Elicit) `item.note` is editorial prose, not a document identifier. Using it as the `<a>` label meant a multi-paragraph note could render as the clickable link text. Fix: always use `m.geschichten_document_link_placeholder()` as the link label; render `item.note` as a `<p class="text-sm text-ink-3">` caption below the link when present. Added `<!-- TODO(#786) -->` so the stub is tracked back to the follow-on reader issue.
Author
Owner

🏛️ Markus Keller (@mkeller) — Architect Review

Approved

This is solid foundational work. The data model decision to move from an unordered Set<Document> to an ordered List<JourneyItem> with a discriminator column is correct — it's the only shape that can represent a curated reading sequence. The ON DELETE SET NULL design for the document FK is the right call; archive deletions shouldn't collapse journeys. The GeschichteSummary projection pattern mirrors PersonSummaryDTO and correctly solves the LazyInitializationException problem inherent in open-in-view: false. All of this is boring, appropriate technology.


Documentation compliance check (required by my review protocol)

Trigger Required update Status
New Flyway migration (V72) adds journey_items, drops geschichten_documents, adds type column db-orm.puml, db-relationships.puml Both updated
New @OneToMany / FK Both DB diagrams Done
New domain concept docs/GLOSSARY.md JourneyItem, Lesereise, Geschichte updated
Updated geschichte package CLAUDE.md domain model table Updated with Geschichte and JourneyItem rows
Updated GeschichteController/GeschichteService description l3-backend-3g-supporting.puml Both component descriptions updated
docs/ARCHITECTURE.md geschichte domain description Updated

The documentation coverage is complete. This is rare — flag it as a positive signal for the team.


Findings

Observation (not a blocker) — GeschichteSpecifications.hasDocument() removal leaves a documented TODO

// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands

The TODO is well-placed and references the follow-on issue. The specification class now has dead import cleanup from the removed hasDocument/documentSubquery methods. Confirmed clean.

Observation — JourneyItemRepository.findAllByGeschichteId() is unused in this PR

The repository method exists but nothing in this PR calls it directly — items are accessed via the owning Geschichte.getItems() collection. This is fine for the follow-on editor, but worth noting that the repository ships before its first call site.

Observation — sentinel UUID approach in GeschichteService.list()

Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
        ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
        : personIds;

This is a pragmatic workaround for JPA's invalid-empty-IN() SQL generation. The personCount = 0 guard in the JPQL query short-circuits the predicate, so the sentinel is never evaluated against real data. The comment explains the contract. Acceptable — but if this pattern recurs, a named constant SENTINEL_UUID would make it self-documenting across future callers.

Observation — No ADR for the STORY/JOURNEY discriminator pattern

The decision to use a single-table discriminator column (type) rather than table-per-type inheritance or a separate Lesereise entity has lasting consequences. The @InheritanceType.SINGLE_TABLE approach was implicitly chosen (plain column, not JPA @Inheritance). This is the right call for the current team size, but an ADR in docs/adr/ would capture why single-table was chosen over @Inheritance(SINGLE_TABLE) or a separate entity. Not a blocker — this PR's scope is the data model, not the inheritance strategy documentation — but flag for the follow-on.


Summary

Domain boundary is clean: journey/ sub-package under geschichte/ is the right location. The DocumentService reservation comment in GeschichteService is a good signal that the team is thinking about boundary enforcement for the editor. The GeschichteSummary interface projection over a JPQL query is architecturally superior to the previous Specification+Sort approach for this read path.

No blockers. Approved.

## 🏛️ Markus Keller (@mkeller) — Architect Review **✅ Approved** This is solid foundational work. The data model decision to move from an unordered `Set<Document>` to an ordered `List<JourneyItem>` with a discriminator column is correct — it's the only shape that can represent a curated reading sequence. The `ON DELETE SET NULL` design for the document FK is the right call; archive deletions shouldn't collapse journeys. The `GeschichteSummary` projection pattern mirrors `PersonSummaryDTO` and correctly solves the `LazyInitializationException` problem inherent in `open-in-view: false`. All of this is boring, appropriate technology. --- ### Documentation compliance check (required by my review protocol) | Trigger | Required update | Status | |---|---|---| | New Flyway migration (V72) adds `journey_items`, drops `geschichten_documents`, adds `type` column | `db-orm.puml`, `db-relationships.puml` | ✅ Both updated | | New `@OneToMany` / FK | Both DB diagrams | ✅ Done | | New domain concept | `docs/GLOSSARY.md` | ✅ `JourneyItem`, `Lesereise`, `Geschichte` updated | | Updated `geschichte` package | `CLAUDE.md` domain model table | ✅ Updated with `Geschichte` and `JourneyItem` rows | | Updated `GeschichteController`/`GeschichteService` description | `l3-backend-3g-supporting.puml` | ✅ Both component descriptions updated | | `docs/ARCHITECTURE.md` `geschichte` domain description | ✅ Updated | The documentation coverage is complete. This is rare — flag it as a positive signal for the team. --- ### Findings **Observation (not a blocker) — `GeschichteSpecifications.hasDocument()` removal leaves a documented TODO** ```java // TODO(lesereisen-editor): restore document filter via journey_items join when editor lands ``` The TODO is well-placed and references the follow-on issue. The specification class now has dead import cleanup from the removed `hasDocument`/`documentSubquery` methods. Confirmed clean. **Observation — `JourneyItemRepository.findAllByGeschichteId()` is unused in this PR** The repository method exists but nothing in this PR calls it directly — items are accessed via the owning `Geschichte.getItems()` collection. This is fine for the follow-on editor, but worth noting that the repository ships before its first call site. **Observation — sentinel UUID approach in `GeschichteService.list()`** ```java Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty()) ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")) : personIds; ``` This is a pragmatic workaround for JPA's invalid-empty-IN() SQL generation. The `personCount = 0` guard in the JPQL query short-circuits the predicate, so the sentinel is never evaluated against real data. The comment explains the contract. Acceptable — but if this pattern recurs, a named constant `SENTINEL_UUID` would make it self-documenting across future callers. **Observation — No ADR for the STORY/JOURNEY discriminator pattern** The decision to use a single-table discriminator column (`type`) rather than table-per-type inheritance or a separate `Lesereise` entity has lasting consequences. The `@InheritanceType.SINGLE_TABLE` approach was implicitly chosen (plain column, not JPA `@Inheritance`). This is the right call for the current team size, but an ADR in `docs/adr/` would capture *why* single-table was chosen over `@Inheritance(SINGLE_TABLE)` or a separate entity. Not a blocker — this PR's scope is the data model, not the inheritance strategy documentation — but flag for the follow-on. --- ### Summary Domain boundary is clean: `journey/` sub-package under `geschichte/` is the right location. The `DocumentService` reservation comment in `GeschichteService` is a good signal that the team is thinking about boundary enforcement for the editor. The `GeschichteSummary` interface projection over a JPQL query is architecturally superior to the previous Specification+Sort approach for this read path. **No blockers. Approved.**
Author
Owner

👨‍💻 Felix Brandt (@felixbrandt) — Developer Review

⚠️ Approved with concerns

The backend is clean and well-structured. A few code quality issues in the frontend and one reliability concern in the backend service worth flagging.


Backend

Entity code style is correctJourneyItem uses the full @Data @NoArgsConstructor @AllArgsConstructor @Builder Lombok stack, @Schema(requiredMode = REQUIRED) on every non-null field. The getDocumentId() virtual getter is well-commented.

@Transactional(readOnly = true) added to getById() — this was missing before; now present. The Hibernate.initialize(g.getItems()) call inside the transaction boundary is the correct fix for the lazy-init problem with open-in-view: false.

Guard clauses and DomainException usage — consistent with project style.

Concern (suggestion) — GeschichteService.list() — business logic in a multi-responsibility method

Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
        ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
        : personIds;
long personCount = (personIds == null) ? 0 : personIds.size();

return geschichteRepository
        .findSummaries(effective, authorId, safePersonIds, personCount)
        .stream()
        .limit(safeLimit)
        .toList();

The sentinel construction and count calculation are two separate data-transformation steps embedded in the method. The method still reads clearly enough, so this is a suggestion not a blocker — but extracting buildSafePersonIds() and keeping the limit logic in one place would make the intent crisper.

Concern (suggestion) — GeschichteServiceTest — verify method uses anyLong() but not the actual value

verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());

The unit tests verify the repository is called but don't assert the correct personCount value is passed. For the person-filter tests, asserting eq(1L) or eq(2L) would prove the counting logic is correct, not just that the method was reached. This is test coverage quality, not a blocker.


Frontend

Blocker — [id]/+page.svelte — note rendered without XSS guard

{#if item.note}
    <p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
{/if}

Svelte's {item.note} text interpolation is auto-escaped — no raw HTML injection is possible here. The entity comment in JourneyItem.java acknowledges this (// only Svelte {note} is auto-safe). This is actually correct as-is.

Clarification: this is safe. Confirmed — not a blocker.

Suggestion — [id]/+page.svelte — double-iterate on g.items

{#if g.items && g.items.some((i) => i.documentId)}
    ...
    {#each g.items.filter((i) => i.documentId) as item (item.id)}

Two iterations over g.items for the same condition check and filter. This is trivial for the sizes involved, but a $derived would be cleaner:

const documentItems = $derived(g.items?.filter(i => i.documentId) ?? []);

Then the template becomes {#if documentItems.length > 0} / {#each documentItems as item (item.id)}. This is a style suggestion, not a blocker.

Suggestion — GeschichteEditor.svelteinitialDocuments prop removed but editor now has no document path

The PR description explicitly calls this out as a stub pending follow-on issue #786. The TODO comment in the template confirms intent. Accepted.

Observation — GeschichtenCard.svelte.test.tsmakeGeschichte factory now returns full Geschichte type (not GeschichteSummary)

The card likely receives GeschichteSummary from the list endpoint. Verify the component accepts GeschichteSummary not Geschichte — if the card prop type is Geschichte it will include items and persons which the list projection doesn't carry. This is a type correctness concern worth checking before merge.


Summary

Backend is solid. Frontend note-rendering is correctly safe (confirmed). One type-correctness concern on the card component prop type worth a quick check. Approving with the card-type suggestion flagged.

## 👨‍💻 Felix Brandt (@felixbrandt) — Developer Review **⚠️ Approved with concerns** The backend is clean and well-structured. A few code quality issues in the frontend and one reliability concern in the backend service worth flagging. --- ### Backend **✅ Entity code style is correct** — `JourneyItem` uses the full `@Data @NoArgsConstructor @AllArgsConstructor @Builder` Lombok stack, `@Schema(requiredMode = REQUIRED)` on every non-null field. The `getDocumentId()` virtual getter is well-commented. **✅ `@Transactional(readOnly = true)` added to `getById()`** — this was missing before; now present. The `Hibernate.initialize(g.getItems())` call inside the transaction boundary is the correct fix for the lazy-init problem with `open-in-view: false`. **✅ Guard clauses and `DomainException` usage** — consistent with project style. **Concern (suggestion) — `GeschichteService.list()` — business logic in a multi-responsibility method** ```java Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty()) ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")) : personIds; long personCount = (personIds == null) ? 0 : personIds.size(); return geschichteRepository .findSummaries(effective, authorId, safePersonIds, personCount) .stream() .limit(safeLimit) .toList(); ``` The sentinel construction and count calculation are two separate data-transformation steps embedded in the method. The method still reads clearly enough, so this is a suggestion not a blocker — but extracting `buildSafePersonIds()` and keeping the limit logic in one place would make the intent crisper. **Concern (suggestion) — `GeschichteServiceTest` — verify method uses `anyLong()` but not the actual value** ```java verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); ``` The unit tests verify the repository is called but don't assert the correct `personCount` value is passed. For the person-filter tests, asserting `eq(1L)` or `eq(2L)` would prove the counting logic is correct, not just that the method was reached. This is test coverage quality, not a blocker. --- ### Frontend **Blocker — `[id]/+page.svelte` — note rendered without XSS guard** ```svelte {#if item.note} <p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p> {/if} ``` Svelte's `{item.note}` text interpolation **is** auto-escaped — no raw HTML injection is possible here. The entity comment in `JourneyItem.java` acknowledges this (`// only Svelte {note} is auto-safe`). This is actually correct as-is. Clarification: this is safe. Confirmed — not a blocker. **Suggestion — `[id]/+page.svelte` — double-iterate on `g.items`** ```svelte {#if g.items && g.items.some((i) => i.documentId)} ... {#each g.items.filter((i) => i.documentId) as item (item.id)} ``` Two iterations over `g.items` for the same condition check and filter. This is trivial for the sizes involved, but a `$derived` would be cleaner: ```svelte const documentItems = $derived(g.items?.filter(i => i.documentId) ?? []); ``` Then the template becomes `{#if documentItems.length > 0}` / `{#each documentItems as item (item.id)}`. This is a style suggestion, not a blocker. **Suggestion — `GeschichteEditor.svelte` — `initialDocuments` prop removed but editor now has no document path** The PR description explicitly calls this out as a stub pending follow-on issue #786. The TODO comment in the template confirms intent. Accepted. **Observation — `GeschichtenCard.svelte.test.ts` — `makeGeschichte` factory now returns full `Geschichte` type (not `GeschichteSummary`)** The card likely receives `GeschichteSummary` from the list endpoint. Verify the component accepts `GeschichteSummary` not `Geschichte` — if the card prop type is `Geschichte` it will include `items` and `persons` which the list projection doesn't carry. This is a type correctness concern worth checking before merge. --- ### Summary Backend is solid. Frontend note-rendering is correctly safe (confirmed). One type-correctness concern on the card component prop type worth a quick check. Approving with the card-type suggestion flagged.
Author
Owner

🔒 Nora "NullX" Steiner — Security Review

Approved

I went through this PR with an adversarial lens. The security-relevant surface is: the new note field (potential XSS vector), the JourneyItem FK behavior (data exposure risk), and endpoint authorization coverage. Here's what I found.


XSS — item.note field

Finding: Confirmed safe — no action needed

// JourneyItem.java
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.
@Column(columnDefinition = "TEXT")
private String note;

The backend stores the note verbatim (correct — no sanitization on write). The frontend renders it with Svelte text interpolation:

<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>

Svelte's {...} syntax HTML-encodes all values. An attacker who stores <script>alert(1)</script> in a note will see it rendered as literal text. The comment in JourneyItem.java correctly documents this contract and the CWE-79 risk. No vulnerability.

The comment also correctly warns that other renderers (email, PDF, RSS) must escape this field. That warning is load-bearing for the follow-on reader issue.


ON DELETE SET NULL behavior — data exposure

Finding: Design is correct — no exposure

When a document is deleted, journey_items.document_id is set to NULL but the item survives. The getDocumentId() accessor returns null. The frontend correctly checks i.documentId before rendering a link:

{#each g.items.filter((i) => i.documentId) as item (item.id)}

Items with document_id = null are filtered out — readers won't see dead links or null UUIDs. The item itself (its note/position) is preserved. No orphaned reference exposure.


Authorization — @RequirePermission coverage

Checked: GeschichteController. All write endpoints (POST, PATCH, DELETE) retain @RequirePermission(Permission.WRITE_ALL) or Permission.BLOG_WRITE. The list endpoint is read-only and unannotated — consistent with other list endpoints in the codebase. The getById() visibility gate (publishing check) is enforced in the service layer, not the controller, which is consistent with the pre-existing pattern.

Note: The GeschichteHttpTest.getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() test validates the 404-visibility-as-security pattern explicitly. This is the right test to have.


JPQL query — injection risk

@Query("""
        SELECT g.id AS id, g.title AS title, ...
        FROM Geschichte g
        WHERE g.status = :effectiveStatus
          AND (:authorId IS NULL OR g.author.id = :authorId)
          AND (:personCount = 0 OR ...)
        """)
List<GeschichteSummary> findSummaries(
        @Param("effectiveStatus") GeschichteStatus effectiveStatus,
        @Param("authorId") UUID authorId,
        @Param("personIds") Collection<UUID> personIds,
        @Param("personCount") long personCount);

All parameters use named @Param binding. No string concatenation. JPQL injection is not possible. Clean.


Sentinel UUID

The UUID.fromString("00000000-0000-0000-0000-000000000000") sentinel is passed as a parameter but the predicate is short-circuited before the IN() clause is evaluated. An attacker cannot influence this value (it is constructed server-side). No exposure.


Summary

No security vulnerabilities found. The CWE-79 comment on note is exemplary — it documents the threat model for future renderers. The authorization coverage is complete. The 404-visibility pattern for drafts is tested.

No blockers. Clean from a security perspective.

## 🔒 Nora "NullX" Steiner — Security Review **✅ Approved** I went through this PR with an adversarial lens. The security-relevant surface is: the new `note` field (potential XSS vector), the `JourneyItem` FK behavior (data exposure risk), and endpoint authorization coverage. Here's what I found. --- ### XSS — `item.note` field **Finding: Confirmed safe — no action needed** ```java // JourneyItem.java // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email // renderer MUST escape this; only Svelte {note} is auto-safe. @Column(columnDefinition = "TEXT") private String note; ``` The backend stores the note verbatim (correct — no sanitization on write). The frontend renders it with Svelte text interpolation: ```svelte <p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p> ``` Svelte's `{...}` syntax HTML-encodes all values. An attacker who stores `<script>alert(1)</script>` in a note will see it rendered as literal text. The comment in `JourneyItem.java` correctly documents this contract and the CWE-79 risk. **No vulnerability.** The comment also correctly warns that _other_ renderers (email, PDF, RSS) must escape this field. That warning is load-bearing for the follow-on reader issue. --- ### `ON DELETE SET NULL` behavior — data exposure **Finding: Design is correct — no exposure** When a document is deleted, `journey_items.document_id` is set to `NULL` but the item survives. The `getDocumentId()` accessor returns `null`. The frontend correctly checks `i.documentId` before rendering a link: ```svelte {#each g.items.filter((i) => i.documentId) as item (item.id)} ``` Items with `document_id = null` are filtered out — readers won't see dead links or null UUIDs. The item itself (its note/position) is preserved. No orphaned reference exposure. --- ### Authorization — `@RequirePermission` coverage **Checked:** `GeschichteController`. All write endpoints (`POST`, `PATCH`, `DELETE`) retain `@RequirePermission(Permission.WRITE_ALL)` or `Permission.BLOG_WRITE`. The list endpoint is read-only and unannotated — consistent with other list endpoints in the codebase. The `getById()` visibility gate (publishing check) is enforced in the service layer, not the controller, which is consistent with the pre-existing pattern. **Note:** The `GeschichteHttpTest.getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE()` test validates the 404-visibility-as-security pattern explicitly. This is the right test to have. --- ### JPQL query — injection risk ```java @Query(""" SELECT g.id AS id, g.title AS title, ... FROM Geschichte g WHERE g.status = :effectiveStatus AND (:authorId IS NULL OR g.author.id = :authorId) AND (:personCount = 0 OR ...) """) List<GeschichteSummary> findSummaries( @Param("effectiveStatus") GeschichteStatus effectiveStatus, @Param("authorId") UUID authorId, @Param("personIds") Collection<UUID> personIds, @Param("personCount") long personCount); ``` All parameters use named `@Param` binding. No string concatenation. JPQL injection is not possible. **Clean.** --- ### Sentinel UUID The `UUID.fromString("00000000-0000-0000-0000-000000000000")` sentinel is passed as a parameter but the predicate is short-circuited before the IN() clause is evaluated. An attacker cannot influence this value (it is constructed server-side). No exposure. --- ### Summary No security vulnerabilities found. The CWE-79 comment on `note` is exemplary — it documents the threat model for future renderers. The authorization coverage is complete. The 404-visibility pattern for drafts is tested. **No blockers. Clean from a security perspective.**
Author
Owner

🧪 Sara Holt (@saraholt) — QA / Test Review

Approved

This PR has one of the best test plans I've seen on this codebase. 22 new tests across three test classes, plus targeted updates to existing test classes. Let me go through each layer.


Test pyramid coverage

Layer New tests Tool Assessment
Unit GeschichteServiceTest — 7 updated/new MockitoExtension
Integration (JPA) JourneyItemIntegrationTest — 9 tests @SpringBootTest + Testcontainers
Integration (projection) GeschichteListProjectionTest — 8 tests @DataJpaTest + Testcontainers
Integration (HTTP) GeschichteHttpTest — 5 tests @SpringBootTest RANDOM_PORT
Controller slice GeschichteControllerTest — updated @WebMvcTest
Frontend component GeschichteEditor.svelte.spec.ts, GeschichtenCard.svelte.spec.ts, GeschichtenCard.svelte.test.ts vitest-browser-svelte

Real PostgreSQL via Testcontainers everywhere — no H2 in sight. The @DataJpaTest uses @AutoConfigureTestDatabase(replace = NONE) + PostgresContainerConfig. The @SpringBootTest classes use @ActiveProfiles("test") + @Import(PostgresContainerConfig.class). Exactly right.


GeschichteHttpTest — specific praise

/**
 * No {@code @Transactional} at class level — that would keep a session open and
 * mask LazyInitializationException caused by open-in-view: false.
 */

This is the correct call. A class-level @Transactional would give false confidence that lazy init is handled, because the test's own transaction would keep the session alive through serialization. By NOT annotating at class level, the test exercises the real production code path: service opens transaction → initializes items → closes transaction → Jackson serializes. The list_returns_200_and_does_not_500_when_stories_have_journey_items test name is a sentence that tells a developer exactly what regression it guards.


JourneyItemIntegrationTest — 9 tests checked

  • @OrderBy round-trip: items inserted in position order 3000/1000/2000 and retrieved in 1000/2000/3000 order
  • Cascade delete: Geschichte deletion removes all JourneyItems
  • Orphan removal: removing item from getItems() list deletes the DB row
  • GeschichteType JOURNEY round-trip
  • GeschichteType defaults to STORY
  • Note-only item (null document FK)
  • Document-backed item exposes documentId
  • ON DELETE SET NULL: deleting the referenced Document nullifies document_id, item survives
  • CHECK constraint violation on empty item

All 9 behaviors tested. The em.flush(); em.clear() pattern is used correctly to force SQL to execute and clear the first-level cache, making the assertions test actual database state. Factory methods are concise.


GeschichteListProjectionTest — concerns

Suggestion — test names for person-filter tests are adequate but could be sharper

void findSummaries_with_two_personIds_uses_AND_semantics()

This is a good name. The person-filter tests cover:

  • personCount = 0 → no filter (disabling path)
  • single person ID → matches only linked stories
  • two person IDs → AND semantics (excludes stories with only one of the two)

One missing case: what happens when a story has more than the two requested persons? e.g., a story tagged A+B+C should appear in results for personIds=[A,B] since it contains both. Not tested. Low priority since the JPQL logic is COUNT(DISTINCT p.id) = personCount which handles this correctly, but a test would prove it.


GeschichteServiceTest — concern

The tests verify findSummaries is called but use anyLong() for personCount. This means the sentinel/count logic in list() is not unit-tested — only the integration tests cover it indirectly. Not a blocker, but an eq(0L) or eq(1L) assertion on the personCount argument would catch a regression if someone changes the counting logic.


Frontend component tests

GeschichtenCard.svelte.spec.ts — factory updated to include type: 'STORY' and items: [] instead of documents: []. Uses vitest-browser-svelte render.

GeschichtenCard.svelte.test.ts — factory now fully typed as Geschichte with all required fields populated. The as Geschichte cast at the end is needed because the Geschichte type from the generated API includes all fields.

GeschichteEditor.svelte.spec.ts — document-related test removed, person-only test updated. Retained test coverage for the remaining behavior.


Migration test coverage

The JourneyItemIntegrationTest and GeschichteListProjectionTest both use Flyway via PostgresContainerConfig. V72 runs as part of the migration stack, so the CHECK constraint, FK behaviors, and @OrderBy are all tested against the real schema.


Summary

No blockers. The test suite for this PR is thorough and correct. The one gap (AND-semantics with a superset of persons) is low risk given the JPQL logic is correct. The personCount unit-test gap is noted as a future improvement.

Approved.

## 🧪 Sara Holt (@saraholt) — QA / Test Review **✅ Approved** This PR has one of the best test plans I've seen on this codebase. 22 new tests across three test classes, plus targeted updates to existing test classes. Let me go through each layer. --- ### Test pyramid coverage | Layer | New tests | Tool | Assessment | |---|---|---|---| | Unit | `GeschichteServiceTest` — 7 updated/new | MockitoExtension | ✅ | | Integration (JPA) | `JourneyItemIntegrationTest` — 9 tests | `@SpringBootTest` + Testcontainers | ✅ | | Integration (projection) | `GeschichteListProjectionTest` — 8 tests | `@DataJpaTest` + Testcontainers | ✅ | | Integration (HTTP) | `GeschichteHttpTest` — 5 tests | `@SpringBootTest` RANDOM_PORT | ✅ | | Controller slice | `GeschichteControllerTest` — updated | `@WebMvcTest` | ✅ | | Frontend component | `GeschichteEditor.svelte.spec.ts`, `GeschichtenCard.svelte.spec.ts`, `GeschichtenCard.svelte.test.ts` | vitest-browser-svelte | ✅ | **Real PostgreSQL via Testcontainers everywhere** — no H2 in sight. The `@DataJpaTest` uses `@AutoConfigureTestDatabase(replace = NONE)` + `PostgresContainerConfig`. The `@SpringBootTest` classes use `@ActiveProfiles("test")` + `@Import(PostgresContainerConfig.class)`. Exactly right. --- ### `GeschichteHttpTest` — specific praise ```java /** * No {@code @Transactional} at class level — that would keep a session open and * mask LazyInitializationException caused by open-in-view: false. */ ``` This is the correct call. A class-level `@Transactional` would give false confidence that lazy init is handled, because the test's own transaction would keep the session alive through serialization. By NOT annotating at class level, the test exercises the real production code path: service opens transaction → initializes items → closes transaction → Jackson serializes. The `list_returns_200_and_does_not_500_when_stories_have_journey_items` test name is a sentence that tells a developer exactly what regression it guards. --- ### `JourneyItemIntegrationTest` — 9 tests checked - `@OrderBy` round-trip: items inserted in position order 3000/1000/2000 and retrieved in 1000/2000/3000 order ✅ - Cascade delete: Geschichte deletion removes all JourneyItems ✅ - Orphan removal: removing item from `getItems()` list deletes the DB row ✅ - `GeschichteType` JOURNEY round-trip ✅ - `GeschichteType` defaults to STORY ✅ - Note-only item (null document FK) ✅ - Document-backed item exposes documentId ✅ - `ON DELETE SET NULL`: deleting the referenced Document nullifies `document_id`, item survives ✅ - CHECK constraint violation on empty item ✅ All 9 behaviors tested. The `em.flush(); em.clear()` pattern is used correctly to force SQL to execute and clear the first-level cache, making the assertions test actual database state. Factory methods are concise. --- ### `GeschichteListProjectionTest` — concerns **Suggestion — test names for person-filter tests are adequate but could be sharper** ```java void findSummaries_with_two_personIds_uses_AND_semantics() ``` This is a good name. The person-filter tests cover: - `personCount = 0` → no filter (disabling path) - single person ID → matches only linked stories - two person IDs → AND semantics (excludes stories with only one of the two) **One missing case:** what happens when a story has _more than_ the two requested persons? e.g., a story tagged A+B+C should appear in results for `personIds=[A,B]` since it contains both. Not tested. Low priority since the JPQL logic is `COUNT(DISTINCT p.id) = personCount` which handles this correctly, but a test would prove it. --- ### `GeschichteServiceTest` — concern The tests verify `findSummaries` is called but use `anyLong()` for `personCount`. This means the sentinel/count logic in `list()` is not unit-tested — only the integration tests cover it indirectly. Not a blocker, but an `eq(0L)` or `eq(1L)` assertion on the personCount argument would catch a regression if someone changes the counting logic. --- ### Frontend component tests `GeschichtenCard.svelte.spec.ts` — factory updated to include `type: 'STORY'` and `items: []` instead of `documents: []`. Uses `vitest-browser-svelte` render. ✅ `GeschichtenCard.svelte.test.ts` — factory now fully typed as `Geschichte` with all required fields populated. The `as Geschichte` cast at the end is needed because the `Geschichte` type from the generated API includes all fields. ✅ `GeschichteEditor.svelte.spec.ts` — document-related test removed, person-only test updated. Retained test coverage for the remaining behavior. ✅ --- ### Migration test coverage The `JourneyItemIntegrationTest` and `GeschichteListProjectionTest` both use Flyway via `PostgresContainerConfig`. V72 runs as part of the migration stack, so the CHECK constraint, FK behaviors, and `@OrderBy` are all tested against the real schema. ✅ --- ### Summary No blockers. The test suite for this PR is thorough and correct. The one gap (AND-semantics with a superset of persons) is low risk given the JPQL logic is correct. The `personCount` unit-test gap is noted as a future improvement. **Approved.**
Author
Owner

🎨 Leonie Voss (@leonievoss) — UX / Accessibility Review

⚠️ Approved with concerns

This is primarily a data model PR — the UI changes are minimal and intentionally stub-level. I'll focus on what shipped and flag what the follow-on reader issue must address.


Changes in scope: geschichten/[id]/+page.svelte

What changed: The document list section now renders JourneyItem links instead of full Document objects. The title and date are replaced by a generic placeholder string.


<a href="/documents/{item.documentId}" ...>
    {m.geschichten_document_link_placeholder()}
</a>

The i18n string geschichten_document_link_placeholder renders as "Dokument öffnen" / "Open document" / "Abrir documento". This is the expected stub state while the journey reader follow-on (#786) is pending.

For this PR: Acceptable as a temporary stub. The TODO comment clearly marks it.
For the follow-on: The link label must be the document title. "Open document" repeated N times violates Nielsen heuristic 2 (match between system and the real world) and makes keyboard navigation non-functional — screen readers would announce the same label for every link. The follow-on issue must resolve document titles before the feature is considered production-ready for our senior audience.


Finding 2 — Minor: Note text has no minimum width / overflow guard

<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>

text-sm is 14px. The project minimum for body text is 16px (per design spec), with 18px preferred for the senior audience. For a supplementary note this may be acceptable, but flag it for the reader UI redesign: notes should be at least 14px with line-height that accommodates wrapping.

At 320px viewport, text-sm with no max-w or break-words could produce overflow if a note has no whitespace. Suggest adding break-words or overflow-wrap-anywhere for the production version.


<a
    href="/documents/{item.documentId}"
    class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
>

The existing person-link pattern in the same file includes explicit focus styles:

class="... focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"

The new document link is missing focus-visible:ring-2 focus-visible:ring-focus-ring. For a keyboard user or screen reader user, navigating the document list would produce no visible focus indicator on this link.

This is an accessibility gap. WCAG 2.1 criterion 2.4.7 (Focus Visible). For a PR that is a stub, I'll call this a concern rather than a blocker — but the follow-on reader must fix this.


Finding 4 — Positive: Keyed {#each} with (item.id)

{#each g.items.filter((i) => i.documentId) as item (item.id)}

Correct key expression. No reconciliation bugs when items are reordered.


Finding 5 — Positive: Section structure

<section class="mt-8 border-t border-line pt-6">
    <h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">

Semantic <section> with <h2> — matches the persons section pattern. Screen readers can navigate between sections.


Summary for the follow-on reader issue

The follow-on must address:

  1. Replace "Dokument öffnen" placeholder with actual document title
  2. Add focus-visible:ring-2 focus-visible:ring-focus-ring to document links
  3. Note text font size: ensure at least 14px with break-words at 320px

For this PR as a data model stub: Approved with the focus ring concern noted. The placeholder is intentional and documented.

Approved (stub state accepted, follow-on issues flagged).

## 🎨 Leonie Voss (@leonievoss) — UX / Accessibility Review **⚠️ Approved with concerns** This is primarily a data model PR — the UI changes are minimal and intentionally stub-level. I'll focus on what shipped and flag what the follow-on reader issue must address. --- ### Changes in scope: `geschichten/[id]/+page.svelte` **What changed:** The document list section now renders `JourneyItem` links instead of full `Document` objects. The title and date are replaced by a generic placeholder string. --- ### Finding 1 — Concern (not a blocker for this PR): Placeholder link is meaningless to users ```svelte <a href="/documents/{item.documentId}" ...> {m.geschichten_document_link_placeholder()} </a> ``` The i18n string `geschichten_document_link_placeholder` renders as "Dokument öffnen" / "Open document" / "Abrir documento". This is the expected stub state while the journey reader follow-on (#786) is pending. **For this PR:** Acceptable as a temporary stub. The TODO comment clearly marks it. **For the follow-on:** The link label must be the document title. "Open document" repeated N times violates Nielsen heuristic 2 (match between system and the real world) and makes keyboard navigation non-functional — screen readers would announce the same label for every link. The follow-on issue **must** resolve document titles before the feature is considered production-ready for our senior audience. --- ### Finding 2 — Minor: Note text has no minimum width / overflow guard ```svelte <p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p> ``` `text-sm` is 14px. The project minimum for body text is 16px (per design spec), with 18px preferred for the senior audience. For a supplementary note this may be acceptable, but flag it for the reader UI redesign: notes should be at least 14px with line-height that accommodates wrapping. At 320px viewport, `text-sm` with no `max-w` or `break-words` could produce overflow if a note has no whitespace. Suggest adding `break-words` or `overflow-wrap-anywhere` for the production version. --- ### Finding 3 — Minor: Link does not have a visible focus ring ```svelte <a href="/documents/{item.documentId}" class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted" > ``` The existing person-link pattern in the same file includes explicit focus styles: ```svelte class="... focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" ``` The new document link is missing `focus-visible:ring-2 focus-visible:ring-focus-ring`. For a keyboard user or screen reader user, navigating the document list would produce no visible focus indicator on this link. **This is an accessibility gap.** WCAG 2.1 criterion 2.4.7 (Focus Visible). For a PR that is a stub, I'll call this a concern rather than a blocker — but the follow-on reader must fix this. --- ### Finding 4 — Positive: Keyed `{#each}` with `(item.id)` ```svelte {#each g.items.filter((i) => i.documentId) as item (item.id)} ``` Correct key expression. No reconciliation bugs when items are reordered. --- ### Finding 5 — Positive: Section structure ```svelte <section class="mt-8 border-t border-line pt-6"> <h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"> ``` Semantic `<section>` with `<h2>` — matches the persons section pattern. Screen readers can navigate between sections. ✅ --- ### Summary for the follow-on reader issue The follow-on **must** address: 1. Replace "Dokument öffnen" placeholder with actual document title 2. Add `focus-visible:ring-2 focus-visible:ring-focus-ring` to document links 3. Note text font size: ensure at least 14px with `break-words` at 320px **For this PR as a data model stub:** Approved with the focus ring concern noted. The placeholder is intentional and documented. **Approved (stub state accepted, follow-on issues flagged).**
Author
Owner

🚀 Tobias Wendt (@tobiwendt) — DevOps / Infrastructure Review

Approved

This PR has no Docker Compose changes, no CI workflow changes, and no new infrastructure components. From a DevOps lens, the only surfaces I care about are:

  1. Flyway migration safety (irreversible DROP TABLE in production)
  2. Testcontainers CI impact
  3. Any secrets or hardcoded credentials in new test code

Flyway V72 migration — production safety

The migration includes a pre-requisite comment block with explicit docker exec commands for taking a pre-migration backup:

-- Production pre-requisite — run BEFORE applying this migration:
--   docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
--     -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
--   docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
--     --table=geschichten_documents \
--     -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'

This is correct practice. The migration comment also includes a reverse procedure if V72 must be rolled back. The DROP TABLE geschichten_documents is the only irreversible step and it is clearly marked as such.

Concern (suggestion): The pg_dump output path is /tmp/pre_v72_backup_YYYYMMDD.sql inside the container. /tmp is ephemeral — it is wiped on container restart. Before running the migration in production, the dump should be copied out of the container immediately:

docker cp familienarchiv-db:/tmp/pre_v72_backup_$(date +%Y%m%d).sql ./backups/

Consider adding this copy step to the migration comment. Not a blocker — the nightly backup layer would also cover this, but belt-and-suspenders for an irreversible DDL step is worth documenting.


Test infrastructure impact

Three new @SpringBootTest / @DataJpaTest classes use Testcontainers (PostgresContainerConfig). Testcontainers with container reuse (if configured) will share the same Postgres instance across test classes in a single JVM run — no additional container cold-start cost per class.

The GeschichteHttpTest uses SpringBootTest.WebEnvironment.RANDOM_PORT, which starts the full application on a random port. This is correct — no port conflicts in parallel CI runs.

No new Docker services, no new CI steps needed.


Credentials check

GeschichteHttpTest uses test-scoped credentials:

private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
private static final String WRITER_PASSWORD = "pass!Geschichte1";

These are test-only credentials created and deleted in @BeforeEach. They are not hardcoded production credentials — the password is set via passwordEncoder.encode(WRITER_PASSWORD). Clean.


Summary

No infrastructure changes. Migration safety documentation is thorough. One suggestion: add a docker cp step to the pre-migration comment to ensure the backup survives container restarts before the irreversible DROP.

No blockers. Approved.

## 🚀 Tobias Wendt (@tobiwendt) — DevOps / Infrastructure Review **✅ Approved** This PR has no Docker Compose changes, no CI workflow changes, and no new infrastructure components. From a DevOps lens, the only surfaces I care about are: 1. Flyway migration safety (irreversible `DROP TABLE` in production) 2. Testcontainers CI impact 3. Any secrets or hardcoded credentials in new test code --- ### Flyway V72 migration — production safety The migration includes a pre-requisite comment block with explicit `docker exec` commands for taking a pre-migration backup: ```sql -- Production pre-requisite — run BEFORE applying this migration: -- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ -- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"' -- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \ -- --table=geschichten_documents \ -- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql' ``` This is correct practice. The migration comment also includes a reverse procedure if V72 must be rolled back. The `DROP TABLE geschichten_documents` is the only irreversible step and it is clearly marked as such. **Concern (suggestion):** The pg_dump output path is `/tmp/pre_v72_backup_YYYYMMDD.sql` inside the container. `/tmp` is ephemeral — it is wiped on container restart. Before running the migration in production, the dump should be copied out of the container immediately: ```bash docker cp familienarchiv-db:/tmp/pre_v72_backup_$(date +%Y%m%d).sql ./backups/ ``` Consider adding this copy step to the migration comment. Not a blocker — the nightly backup layer would also cover this, but belt-and-suspenders for an irreversible DDL step is worth documenting. --- ### Test infrastructure impact Three new `@SpringBootTest` / `@DataJpaTest` classes use Testcontainers (`PostgresContainerConfig`). Testcontainers with container reuse (if configured) will share the same Postgres instance across test classes in a single JVM run — no additional container cold-start cost per class. The `GeschichteHttpTest` uses `SpringBootTest.WebEnvironment.RANDOM_PORT`, which starts the full application on a random port. This is correct — no port conflicts in parallel CI runs. No new Docker services, no new CI steps needed. --- ### Credentials check `GeschichteHttpTest` uses test-scoped credentials: ```java private static final String WRITER_EMAIL = "geschichten-http-writer@test.de"; private static final String WRITER_PASSWORD = "pass!Geschichte1"; ``` These are test-only credentials created and deleted in `@BeforeEach`. They are not hardcoded production credentials — the password is set via `passwordEncoder.encode(WRITER_PASSWORD)`. Clean. --- ### Summary No infrastructure changes. Migration safety documentation is thorough. One suggestion: add a `docker cp` step to the pre-migration comment to ensure the backup survives container restarts before the irreversible DROP. **No blockers. Approved.**
Author
Owner

📋 Elicit — Requirements Engineering Review

⚠️ Approved with concerns

I'm reviewing this against the original issue #750 and the PR description to check for requirements completeness, unresolved ambiguities, and scope compliance.


Requirements traceability

The PR description states it "Closes #750" and lists the following deliverables:

Stated deliverable Delivered Notes
GeschichteType enum (STORY/JOURNEY) Default STORY for existing rows
JourneyItem entity position, optional document_id, optional note
V72 Flyway migration Adds type, creates journey_items, migrates, drops junction table
GeschichteSummary projection Replaces entity-based list()
Frontend types + routes stub api.ts updated, [id] renders document links with placeholder
Document picker removed from editor Follow-on issue for journey editor referenced

All stated deliverables are present.


Ambiguities and open questions surfaced by the diff

OQ-001: What happens to existing STORY-type Geschichten that had documents via the old geschichten_documents table?

The migration migrates existing geschichten_documents rows into journey_items ordered by meta_date ASC NULLS LAST. The assumption is documented:

ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order existed. Ordering by meta_date is a plausible default a Lesereise lets curators re-sequence.

However, existing Geschichten had type = STORY (default). These STORY-type Geschichten now have JourneyItem entries in journey_items. The frontend renders document-backed items for ALL Geschichte types (not just JOURNEY):

{#if g.items && g.items.some((i) => i.documentId)}

Concern: A STORY with previously linked documents will now render its documents as journey items with the "Dokument öffnen" placeholder instead of the full document title + date it showed before. This is a regression in the existing STORY experience, acknowledged in:

ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window.

This is accepted by the author. From a requirements perspective, this is a visible regression with a documented rationale and a follow-on dependency. I'd recommend the follow-on issue (#786) explicitly lists "restore document title display for STORY-type Geschichten" as a must-have acceptance criterion to prevent it from being forgotten.

OQ-002: documentId parameter removed from the GET /api/geschichten endpoint

The old endpoint supported ?documentId=<uuid> for filtering stories by a specific document. This has been removed with a TODO comment to restore it via journey_items. Any callers of this parameter (internal links, external tools, browser history) will silently receive unfiltered results.

Not a blocker for the data model PR, but the removal is a breaking change to the public API surface. The TODO should reference the follow-on issue number.

OQ-003: The GeschichteSummary projection does not include type-specific items

The list endpoint returns GeschichteSummary, which has no items. Card renderers cannot distinguish a JOURNEY from a STORY at the list level (only the type field is available). This is intentional for this PR, but the UX implication is that the list view cannot show a "reading journey" affordance until the follow-on adds it.


Scope compliance

The PR correctly defers the journey editor, drag-reorder, and full reader experience to follow-on issues. The stub state is clearly marked with TODO comments referencing issue numbers. This is good issue-driven development practice.


Summary

The PR delivers what issue #750 requires. The two known regressions (STORY document display degradation, documentId filter removal) are acknowledged in migration comments and follow a documented assumption. The main requirements concern is that ASSUMPTION AS-002's impact should be explicitly captured in the follow-on reader issue as a must-fix acceptance criterion.

No blockers for the data model deliverable. Approved with the recommendation to update issue #786 to capture the STORY display regression as a must-have.

## 📋 Elicit — Requirements Engineering Review **⚠️ Approved with concerns** I'm reviewing this against the original issue #750 and the PR description to check for requirements completeness, unresolved ambiguities, and scope compliance. --- ### Requirements traceability The PR description states it "Closes #750" and lists the following deliverables: | Stated deliverable | Delivered | Notes | |---|---|---| | `GeschichteType` enum (`STORY`/`JOURNEY`) | ✅ | Default `STORY` for existing rows | | `JourneyItem` entity | ✅ | `position`, optional `document_id`, optional `note` | | V72 Flyway migration | ✅ | Adds `type`, creates `journey_items`, migrates, drops junction table | | `GeschichteSummary` projection | ✅ | Replaces entity-based `list()` | | Frontend types + routes stub | ✅ | `api.ts` updated, `[id]` renders document links with placeholder | | Document picker removed from editor | ✅ | Follow-on issue for journey editor referenced | All stated deliverables are present. --- ### Ambiguities and open questions surfaced by the diff **OQ-001: What happens to existing `STORY`-type Geschichten that had documents via the old `geschichten_documents` table?** The migration migrates existing `geschichten_documents` rows into `journey_items` ordered by `meta_date ASC NULLS LAST`. The assumption is documented: > ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order existed. Ordering by meta_date is a plausible default a Lesereise lets curators re-sequence. However, existing Geschichten had `type = STORY` (default). These STORY-type Geschichten now have `JourneyItem` entries in `journey_items`. The frontend renders document-backed items for ALL Geschichte types (not just JOURNEY): ```svelte {#if g.items && g.items.some((i) => i.documentId)} ``` **Concern:** A STORY with previously linked documents will now render its documents as journey items with the "Dokument öffnen" placeholder instead of the full document title + date it showed before. This is a regression in the existing STORY experience, acknowledged in: > ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block; this block visibly degrades to generic links (loss of per-document title AND date) for ALL current readers during the stub window. This is accepted by the author. From a requirements perspective, this is a visible regression with a documented rationale and a follow-on dependency. I'd recommend the follow-on issue (#786) explicitly lists "restore document title display for STORY-type Geschichten" as a must-have acceptance criterion to prevent it from being forgotten. **OQ-002: `documentId` parameter removed from the GET `/api/geschichten` endpoint** The old endpoint supported `?documentId=<uuid>` for filtering stories by a specific document. This has been removed with a TODO comment to restore it via `journey_items`. Any callers of this parameter (internal links, external tools, browser history) will silently receive unfiltered results. Not a blocker for the data model PR, but the removal is a breaking change to the public API surface. The TODO should reference the follow-on issue number. **OQ-003: The `GeschichteSummary` projection does not include `type`-specific items** The list endpoint returns `GeschichteSummary`, which has no `items`. Card renderers cannot distinguish a JOURNEY from a STORY at the list level (only the `type` field is available). This is intentional for this PR, but the UX implication is that the list view cannot show a "reading journey" affordance until the follow-on adds it. --- ### Scope compliance The PR correctly defers the journey editor, drag-reorder, and full reader experience to follow-on issues. The stub state is clearly marked with TODO comments referencing issue numbers. This is good issue-driven development practice. --- ### Summary The PR delivers what issue #750 requires. The two known regressions (STORY document display degradation, `documentId` filter removal) are acknowledged in migration comments and follow a documented assumption. The main requirements concern is that ASSUMPTION AS-002's impact should be explicitly captured in the follow-on reader issue as a must-fix acceptance criterion. **No blockers for the data model deliverable. Approved with the recommendation to update issue #786 to capture the STORY display regression as a must-have.**
marcel added 1 commit 2026-06-08 16:02:10 +02:00
fix(review): GeschichtenCard uses GeschichteSummary type; focus-visible on journey links; fix stale tests
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
df5d880e09
- GeschichtenCard.svelte: use GeschichteSummary instead of Geschichte
  (list endpoint returns summaries; no items/createdAt/updatedAt needed)
- GeschichtenCard.svelte.test.ts: factory returns GeschichteSummary with
  lean author shape; drop Geschichte-only fields (createdAt, groups, etc.)
- geschichten/[id]/+page.svelte: add focus:outline-none focus-visible:ring-2
  focus-visible:ring-focus-ring to journey item document links (WCAG 2.4.7)
- page.svelte.test.ts ([id]): replace stale documents[] factory field with
  items[]; test now checks placeholder text + note caption
- page.svelte.test.ts (new): remove removed initialDocuments from baseData;
  rename test to reflect that only initialPersons is passed through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-06-08 22:15:13 +02:00
feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) (#788)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
0780c09bb4
## Summary

Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751).

**Completed in this PR:**
- `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`)
- `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible)
- `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`
- V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items`
- `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests)
- Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak)
- `DocumentService.getSummaryById` — lean lookup without tag-color resolution
- `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId`
- DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable<String> note`), `JourneyReorderDTO`

**Still in progress (WIP):**
- `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6)
- `GeschichteService.getById` → returns `GeschichteView` (Task 7)
- New endpoints on `GeschichteController` + slice tests (Task 8)
- Frontend error codes + i18n + `npm run generate:api` (Task 9)

## Commits

- `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
- `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
- `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
- `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
- `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs

## Test plan

- [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass
- [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged
- [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #788
marcel added 69 commits 2026-06-09 12:08:59 +02:00
Registers JsonNullableModule globally so JsonNullable<String> in
JourneyItemUpdateDTO can distinguish absent (unchanged) from explicit
null (clear field) on PATCH operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds JOURNEY_ITEM_ADDED, JOURNEY_ITEM_REMOVED, JOURNEY_ITEMS_REORDERED
(last is ROLLUP_ELIGIBLE — drag-heavy editing produces many events).
Adds JOURNEY_ITEM_NOT_FOUND (404) and JOURNEY_ITEM_POSITION_CONFLICT
(409) to ErrorCode for IDOR protection and concurrent-edit feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DEFERRABLE INITIALLY DEFERRED allows mid-transaction position swaps
during reorder (checked at COMMIT, not per-row). CHECK (position > 0)
guards against off-by-one in the append path. Both verified by
JourneyItemConstraintsTest via raw pg_constraint query + jdbcTemplate
inserts against a real postgres:16-alpine container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentSummary: lean document projection for journey item embedding —
skips tag-color resolution (getSummaryById), includes receiverCount
(0 when no receivers, non-null). JourneyItemView: response record for
item CRUD and GET. GeschichteView: detail response with summarised
author {id, displayName} to prevent AppUser email/group leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(geschichte): extend JourneyItemRepository and add item DTOs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
2ad5c36e3c
Repository: findByIdAndGeschichteId (IDOR-safe lookup),
findByGeschichteIdOrderByPosition, findIdsByGeschichteId (Set<UUID> for
set-equality reorder check), findMaxPositionByGeschichteId, countByGeschichteId.
DTOs: JourneyItemCreateDTO (documentId+note), JourneyItemUpdateDTO
(JsonNullable<String> note for 3-way PATCH), JourneyReorderDTO (List<UUID>).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds POST/PATCH/DELETE/PUT-reorder endpoints for journey items, backed by
JourneyItemService. Replaces jackson-databind-nullable (Jackson 2.x, incompatible
with Spring Boot 4 / Jackson 3.x) with Optional<String> three-way PATCH semantics:
null = absent/no-op, empty = clear, present = set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT to the frontend
ErrorCode union and getErrorMessage() switch. Adds de/en/es translations.
Regenerates api.ts from the current OpenAPI spec (needs a second run once the
backend is restarted with the new endpoints compiled in).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs: update GLOSSARY for JourneyItem view types; add ADR-035
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
6fc5ce6ddd
Fixes GLOSSARY position-step value (1000→10), adds DEFERRABLE constraint note,
and documents GeschichteView, JourneyItemView, and DocumentSummary read-model types.

ADR-035 records the decision to use Optional<String> for three-way PATCH semantics
instead of jackson-databind-nullable (which targets Jackson 2.x and is incompatible
with Spring Boot 4.0 / Jackson 3.x).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(review): address PR #788 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m53s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
4eb6abd920
- GlobalExceptionHandler maps uq_journey_items_geschichte_position constraint
  violation to HTTP 409 JOURNEY_ITEM_POSITION_CONFLICT
- JourneyItemService.reorder() rejects duplicate IDs before set-equality check
  to prevent silent position overwrite
- JourneyItemRepository removes orphaned findAllByGeschichteId method
- GeschichteView removes stale com.fasterxml.jackson import
- Tests: add appendItem_returns409_on_position_conflict (controller),
  reorder_returns400_when_itemIds_contain_duplicates (service)
- Fix JourneyItemIntegrationTest compilation after repository cleanup
- db-orm.puml annotates journey_items position with CHECK + UNIQUE DEFERRABLE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(review): friendlier i18n message for journey position conflict error
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
97f22e1ce8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(journeyitem): use specific error codes in append() — JOURNEY_AT_CAPACITY and GESCHICHTE_TYPE_MISMATCH
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
598ad622e7
- JourneyItemService.append(): replace VALIDATION_ERROR with GESCHICHTE_TYPE_MISMATCH (409 conflict)
  for non-JOURNEY type guard and JOURNEY_AT_CAPACITY (409 conflict) for 100-item cap
- JourneyItemServiceTest: update assertions to expect the new specific error codes
- CLAUDE.md: expand geschichte/ package table entry with GeschichteQueryService and journeyitem/ sub-domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JourneyItemService no longer injects GeschichteRepository directly.
GeschichteQueryService gains findById() so JourneyItemService.append()
can load the Geschichte entity via the service layer, satisfying the
cross-domain layering rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the per-item save() loop in reorder() with a single
saveAll() call, reducing database round-trips for large journeys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds de/en/es translations for the case where a JourneyItem's linked
document has been deleted (document field is null), so the UI PR can
display a meaningful fallback string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore(test): remove JacksonConfig from GeschichteControllerTest @Import
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
3f36d2a7f1
JacksonConfig was deleted (empty placeholder) — remove the now-stale
import and @Import reference from the controller slice test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GeschichteService.getById() now returns the Geschichte entity (with the
DRAFT visibility guard intact). The controller calls journeyItemService.getItems()
and geschichteService.toView() to assemble the GeschichteView, removing the
need for GeschichteService to hold a reference to JourneyItemService.
Tests updated accordingly: GeschichteServiceTest tests toView() directly;
GeschichteControllerTest stubs both service calls; integration test uses the
two-step pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add findByGeschichteIdWithDocument() to JourneyItemRepository with a
LEFT JOIN FETCH on document. getItems() now uses this query so that all
documents for a journey's items are loaded in a single SQL round-trip.
toView() now reads item.getDocument() directly from the already-fetched
association instead of issuing a separate documentService.getSummaryById()
call per item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add GeschichteQueryService component to the L3 supporting-domains diagram.
Remove the now-deleted Rel(geschSvc, journeyItemSvc, "Delegates getItems()")
arrow and add the correct Rel(journeyItemSvc, geschQuerySvc, ...) arrow that
reflects the actual dependency direction after the refactor in the prior commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add two service-level integration tests to JourneyItemIntegrationTest:
- append_persists_item_at_position_10: verifies that the first append to an
  empty journey creates an item at position 10 in the DB.
- reorder_swaps_positions_atomically: appends two items then reorders them,
  asserting the DB reflects the new position assignment.
Both tests use the SecurityContextHolder authentication pattern from
GeschichteServiceIntegrationTest and mock S3Client to avoid MinIO connections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
doesNotExist() asserts the key is absent from the JSON object, but Jackson
serializes a null Optional<String> as {"note": null} — the key is present with
a null value. nullValue() correctly matches that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clarify in the Javadoc that getSummaryById intentionally skips scope checks
and tag-colour resolution. This is safe under the current single-tenant model
and is explicitly used by JourneyItemService.append() to validate that a linked
document exists before persisting a JourneyItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add @Operation annotation to reorderItems() clarifying that itemIds must
contain ALL item IDs for the journey in the desired order — a partial list
returns 400 Bad Request. This surfaces the contract in the generated
OpenAPI spec and Swagger UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(geschichte): extract PersonNameFormatter to eliminate duplicated name-join logic
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
1108277472
Create PersonNameFormatter with a single static join(firstName, lastName) method.
Replace the inline string concatenation in GeschichteService.toView() and the
private join() method in JourneyItemService with calls to PersonNameFormatter.join().
The new helper handles null-safety and trimming consistently in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-inject JourneyItemService into GeschichteService (no cycle:
JourneyItemService → GeschichteQueryService, not GeschichteService).
Add getView(UUID) that loads the Geschichte and its items in a single
@Transactional(readOnly=true) session. Controller now delegates to
getView() instead of making two separate service calls. Tests updated
to stub getView() and cover the new method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(document): rename getSummaryById to findSummaryByIdInternal to signal scope-check bypass
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
99111273e5
The method intentionally skips permission checks and tag-colour resolution.
Renaming it to findSummaryByIdInternal makes the internal-only contract
visible at every call site, closing the latent CWE-284 risk flagged in
the PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(journeyitem): verify findSummaryByIdInternal never called before JOURNEY-type guard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
77cbbd34a0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Self-check: GeschichteView.items present; type emitted as 'STORY'|'JOURNEY' union literal.
List endpoint returns GeschichteSummary[]; detail endpoint returns GeschichteView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--c-journey-bg/text/border wired in light :root, dark @media, dark [data-theme]
blocks. Exposed via @theme inline as color-journey-tint/journey/journey-border.
Light: #B46820 on #FEF0E6 ≈ 4.6:1 AA at 12px bold. Dark: #E8862A on #3A2A1A ≈ 4.7:1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(geschichte): add utils.ts (formatAuthorName/DisplayName/PublishedAt), update README
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m14s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
97026fec11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces aria-label="Kuratorennotiz" with m.journey_interlude_aria_label()
so screen readers get the correct label in all three supported locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the 3-line inline join with the shared formatAuthorName helper from
utils.ts. Test switches from CSS class string assertion to getComputedStyle
for the badge font-size check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the confirm-then-delete flow out of StoryReader and JourneyReader into
the single [id]/+page.svelte owner. Both reader components gain an optional
ondelete prop — the delete button calls ondelete?.() so the handler is opt-in
and never duplicated. Tests verify the prop is called on click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 tests covering null/undefined inputs, partial names, email fallback,
and TZ-safe date slicing for formatPublishedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CSS class string assertion was fragile — class names can change without
breaking the actual layout. DOM measurement via getBoundingClientRect is the
correct way to verify computed height meets WCAG 2.2 minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies radioGroupNav action moves selection forward and wraps backward
so keyboard users can navigate the STORY/JOURNEY cards without a mouse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(geschichten-new): add request to makeEvent and vi.fn wrapper to createApiClient mock
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
994772564a
Sentry's wrapLoadWithSentry reads event.request.method — the test's makeEvent
now provides a real Request object. createApiClient mock was a plain function;
wrapping with vi.fn() enables vi.mocked(...).mockReturnValue in individual tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds deleteError $state to [id]/+page.svelte, parses backend error via
parseBackendError/getErrorMessage on !res.ok, and displays a role=alert
paragraph. Adds two browser-tier tests: success path (goto called) and
error path (alert visible, goto not called).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous #b46820 on #fef0e6 = 3.81:1 — fails 4.5:1 required for text-xs
(12px normal text). #7a3f0e on #fef0e6 = 7.4:1 — passes WCAG AAA.
Dark-mode #e8862a on #3a2a1a = 5.16:1 — already passing, unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JourneyReader filters items to only those where document != null before
passing them here — the ! assertion is valid by caller invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Function names already communicate intent. Comments that restate the
function name add noise without explaining why.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The action was writing aria-checked directly and then firing onChange,
which also triggered Svelte's own aria-checked={selected === type} binding.
Double-ownership: action now only calls focus() + onChange(value);
Svelte owns the attribute update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Screen readers now announce the hint paragraph text on focus when no type
is selected, so users hear why the button is disabled without having to
click it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(storyreader): verify person chip link meets 44px touch-target height
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
75de56928e
Mirrors the getBoundingClientRect pattern from JourneyItemCard.svelte.spec.ts.
Tests actual rendered height rather than presence of a CSS class string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a landmark or widget role, aria-label on a generic <div> is
silently ignored by most screen readers (ARIA spec). Adding role="note"
gives the element an ARIA role that accepts an accessible name, making
the interlude label actually announced.

Also adds a test asserting role="note" and the matching aria-label are
both present on the same element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(geschichte): use formatPublishedAt() in GeschichteListRow — remove DRY violation
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
c50f04bafa
The inline publishedAt $derived.by() duplicated the exact logic that
formatPublishedAt() in utils.ts encapsulates. Replace it with the
shared helper and drop the now-unused formatDate import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(c4): update l3-frontend-3c-people-stories for STORY/JOURNEY dispatch
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m12s
c131507e30
geschichten components now describe the type-based reader split
(StoryReader / JourneyReader / JourneyItemCard / JourneyInterlude),
the TypeSelector creation flow, and the full set of API endpoints
(including DELETE /api/geschichten/{id} and GET /api/persons/{id}
for person pre-population).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(tests): resolve 10 failing browser-mode tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 6m5s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
d5441d3e55
- Import layout.css in test-setup so Tailwind utilities (text-xs,
  min-h-[44px]) apply in vitest-browser — fixes computed-style assertions
  for badge font-size and touch-target height
- radioGroupNav: write aria-checked directly on radio buttons on arrow-key
  navigation, not only via the optional onChangeFn callback
- DashboardNeedsMetadata spec: tighten footer-link matcher from /50/ to
  /Alle 50/ — avoids strict-mode collision with row link whose relative
  time text also contains "50" (uploadedAt is exactly 50 days ago today)
- geschichten/[id] page spec: add missing await on userEvent.click before
  confirmService.settle() in both delete tests
- TypeSelector spec: replace storyCard.focus() (not on vitest-browser
  Locator) with userEvent.click(); force-dispatch aria-disabled Weiter
  click via element.click() to bypass Playwright actionability check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(tests): resolve 43 regressions caused by layout.css import in test-setup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
9be24f2613
Importing layout.css in test-setup.ts activated Tailwind's responsive
breakpoint classes (hidden lg:flex, hidden md:block, etc.), making
42 elements invisible at the default narrow Playwright test viewport.

Revert the CSS import. Instead, add inline style attributes to the three
components whose tests measure computed properties (min-height, font-size)
— these values match what the Tailwind classes produce, so the real app
appearance is unchanged.

Also fix goto mock leakage in the geschichten/[id] delete-failure test:
the delete-success test's goto('/geschichten') call was not cleared before
the failure test ran. Add beforeEach(vi.clearAllMocks) to reset mock state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore(merge): resolve api.ts conflict with feat/issue-750-lesereisen-data-model
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
bee055e615
Drop stale JourneyItem/JourneyItemCreateDTO schemas — removed in base
branch when api.ts was regenerated; neither type is referenced in
frontend code (JourneyItemView is the read model used instead).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/issue-750-lesereisen-data-model:feat/issue-750-lesereisen-data-model
git checkout feat/issue-750-lesereisen-data-model
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#787