feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) #788

Merged
marcel merged 41 commits from feat/issue-751-journey-item-crud-api into feat/issue-750-lesereisen-data-model 2026-06-08 22:15:12 +02:00
Owner

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):

  • JourneyItemServiceappend, 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
## 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
marcel added 5 commits 2026-06-08 16:39:37 +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>
marcel added 5 commits 2026-06-08 17:06:49 +02:00
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>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid TDD execution. 60 tests, all green. The Optional<String> three-way PATCH semantics solution is clever and well-documented. Layering is clean.

Blockers

Dead import in GeschichteView.java

import com.fasterxml.jackson.annotation.JsonInclude;

This import exists in the file but @JsonInclude is never used. Remove it.

findAllByGeschichteId orphan in JourneyItemRepository
The original findAllByGeschichteId(UUID) is still in the repo, now superseded everywhere by findByGeschichteIdOrderByPosition(UUID). Two methods that do the same thing (unordered vs ordered) invite the wrong one being called in future code. Either remove findAllByGeschichteId or document the distinction with a comment on when each is appropriate. I'd remove it.

Suggestions

toSummary() and toView() are public but only used internally
Both are called only within JourneyItemService itself (toView is also called by getItems, append, updateNote, reorder). The only external caller is GeschichteService.getItems()JourneyItemService.getItems(). The toSummary(Document) method is an implementation detail — it should be package-private. If tests need it, they're in the same package (they are — journeyitem), so package-private is fine.

getItems() lacks @Transactional(readOnly = true)
Every other read method in the project follows the convention of @Transactional(readOnly = true) for reads that return entity graphs. getItems() calls toView() which calls documentService.getSummaryById() — but since there's no lazy association traversal in the view path now, this is minor. Still worth adding for consistency.

savedItemWithDoc() factory in JourneyItemServiceTest is never called
Dead test code. Remove it.

PR description is stale
The "Still in progress (WIP)" section lists Tasks 6–9 as pending, but they're all done and committed. Update the description to reflect the actual completed state.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid TDD execution. 60 tests, all green. The `Optional<String>` three-way PATCH semantics solution is clever and well-documented. Layering is clean. ### Blockers **Dead import in `GeschichteView.java`** ```java import com.fasterxml.jackson.annotation.JsonInclude; ``` This import exists in the file but `@JsonInclude` is never used. Remove it. **`findAllByGeschichteId` orphan in `JourneyItemRepository`** The original `findAllByGeschichteId(UUID)` is still in the repo, now superseded everywhere by `findByGeschichteIdOrderByPosition(UUID)`. Two methods that do the same thing (unordered vs ordered) invite the wrong one being called in future code. Either remove `findAllByGeschichteId` or document the distinction with a comment on when each is appropriate. I'd remove it. ### Suggestions **`toSummary()` and `toView()` are `public` but only used internally** Both are called only within `JourneyItemService` itself (`toView` is also called by `getItems`, `append`, `updateNote`, `reorder`). The only external caller is `GeschichteService.getItems()` → `JourneyItemService.getItems()`. The `toSummary(Document)` method is an implementation detail — it should be package-private. If tests need it, they're in the same package (they are — `journeyitem`), so package-private is fine. **`getItems()` lacks `@Transactional(readOnly = true)`** Every other read method in the project follows the convention of `@Transactional(readOnly = true)` for reads that return entity graphs. `getItems()` calls `toView()` which calls `documentService.getSummaryById()` — but since there's no lazy association traversal in the view path now, this is minor. Still worth adding for consistency. **`savedItemWithDoc()` factory in `JourneyItemServiceTest` is never called** Dead test code. Remove it. **PR description is stale** The "Still in progress (WIP)" section lists Tasks 6–9 as pending, but they're all done and committed. Update the description to reflect the actual completed state.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: 🚫 Changes requested

Good IDOR protection via findByIdAndGeschichteId. Good auth on all four endpoints with @RequirePermission(Permission.BLOG_WRITE). The AuthorView email containment is correct. One critical gap needs to be fixed before merge.

Blockers

JOURNEY_ITEM_POSITION_CONFLICT is defined but never thrown — concurrent write causes HTTP 500

ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT was added in ErrorCode.java, mirrored in frontend/src/lib/shared/errors.ts, and has i18n keys. But no code in JourneyItemService catches the DataIntegrityViolationException that PostgreSQL throws when two concurrent append or reorder calls hit the DEFERRABLE UNIQUE constraint at commit time.

Current behavior on a race: the DataIntegrityViolationException propagates to GlobalExceptionHandler, which maps unknown exceptions to INTERNAL_ERROR + HTTP 500. The client gets a 500 with no useful error code instead of a 409.

Fix in JourneyItemService:

import org.springframework.dao.DataIntegrityViolationException;

@Transactional
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
    // ...
    try {
        JourneyItem saved = journeyItemRepository.save(item);
        // audit...
        return toView(saved);
    } catch (DataIntegrityViolationException e) {
        throw DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
                "Position conflict on append — concurrent modification detected");
    }
}

Apply the same catch to reorder(). The DEFERRABLE constraint fires at transaction commit, so the save() call inside the transaction won't throw — the exception fires when the @Transactional proxy commits. You'll need to catch it at the service boundary (the public method level), or catch it in the controller and wrap it there.

Actually — because the constraint is DEFERRABLE INITIALLY DEFERRED, the exception fires at the commit point, which is at the end of the @Transactional method, outside the try-catch you'd write inside the method body. The correct place is to translate it in GlobalExceptionHandler:

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException ex) {
    String msg = ex.getMessage() != null ? ex.getMessage() : "";
    if (msg.contains("uq_journey_items_geschichte_position")) {
        return ResponseEntity.status(409).body(new ErrorResponse(
            ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "Position conflict"));
    }
    // fallback to generic conflict or 500
    return ResponseEntity.status(409).body(new ErrorResponse(
        ErrorCode.VALIDATION_ERROR, "Data integrity violation"));
}

Add a test verifying the 409 mapping in GeschichteControllerTest or a targeted DataIntegrityViolationException → 409 test.

Suggestions

displayName fallback to email is intentional — documented, acceptable
When firstName and lastName are both blank, displayName falls back to the user's email. This means the author's email is visible in the public story view. This is a product decision, not a bug (the AuthorView record itself doesn't have an email field, but the display value IS the email). ADR-035 should note this trade-off explicitly.

currentUser() performs a DB lookup on every write operation
Every write path (append, updateNote, delete, reorder) calls currentUser() which calls userService.findByEmail(auth.getName()) — a database query per request just to get the actor UUID for the audit log. This is consistent with the rest of the codebase (checked GeschichteService) so not a blocker, but worth tracking as a future optimization (cache in SecurityContextHolder or load from Principal).

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: 🚫 Changes requested** Good IDOR protection via `findByIdAndGeschichteId`. Good auth on all four endpoints with `@RequirePermission(Permission.BLOG_WRITE)`. The `AuthorView` email containment is correct. One critical gap needs to be fixed before merge. ### Blockers **`JOURNEY_ITEM_POSITION_CONFLICT` is defined but never thrown — concurrent write causes HTTP 500** `ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT` was added in `ErrorCode.java`, mirrored in `frontend/src/lib/shared/errors.ts`, and has i18n keys. But no code in `JourneyItemService` catches the `DataIntegrityViolationException` that PostgreSQL throws when two concurrent `append` or `reorder` calls hit the DEFERRABLE UNIQUE constraint at commit time. Current behavior on a race: the `DataIntegrityViolationException` propagates to `GlobalExceptionHandler`, which maps unknown exceptions to `INTERNAL_ERROR` + HTTP 500. The client gets a 500 with no useful error code instead of a 409. Fix in `JourneyItemService`: ```java import org.springframework.dao.DataIntegrityViolationException; @Transactional public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) { // ... try { JourneyItem saved = journeyItemRepository.save(item); // audit... return toView(saved); } catch (DataIntegrityViolationException e) { throw DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "Position conflict on append — concurrent modification detected"); } } ``` Apply the same catch to `reorder()`. The DEFERRABLE constraint fires at transaction commit, so the `save()` call inside the transaction won't throw — the exception fires when the `@Transactional` proxy commits. You'll need to catch it at the service boundary (the public method level), or catch it in the controller and wrap it there. Actually — because the constraint is DEFERRABLE INITIALLY DEFERRED, the exception fires at the commit point, which is at the end of the `@Transactional` method, *outside* the try-catch you'd write inside the method body. The correct place is to translate it in `GlobalExceptionHandler`: ```java @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException ex) { String msg = ex.getMessage() != null ? ex.getMessage() : ""; if (msg.contains("uq_journey_items_geschichte_position")) { return ResponseEntity.status(409).body(new ErrorResponse( ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "Position conflict")); } // fallback to generic conflict or 500 return ResponseEntity.status(409).body(new ErrorResponse( ErrorCode.VALIDATION_ERROR, "Data integrity violation")); } ``` Add a test verifying the 409 mapping in `GeschichteControllerTest` or a targeted `DataIntegrityViolationException` → 409 test. ### Suggestions **`displayName` fallback to email is intentional — documented, acceptable** When `firstName` and `lastName` are both blank, `displayName` falls back to the user's email. This means the author's email is visible in the public story view. This is a product decision, not a bug (the `AuthorView` record itself doesn't have an email field, but the display value IS the email). ADR-035 should note this trade-off explicitly. **`currentUser()` performs a DB lookup on every write operation** Every write path (`append`, `updateNote`, `delete`, `reorder`) calls `currentUser()` which calls `userService.findByEmail(auth.getName())` — a database query per request just to get the actor UUID for the audit log. This is consistent with the rest of the codebase (checked `GeschichteService`) so not a blocker, but worth tracking as a future optimization (cache in `SecurityContextHolder` or load from Principal).
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: 🚫 Changes requested

Clean layering. The GeschichteRepository injection into JourneyItemService (instead of GeschichteService) correctly avoids a constructor injection cycle — Spring Framework 7 would refuse the cycle. Good ADR for the Optional<String> decision.

Blockers

DB constraint diagrams not updated for V73

V73 adds two constraints to journey_items:

  • UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
  • CHECK (position > 0)

Per the doc update rule: any Flyway migration that modifies a table requires updating docs/architecture/db/db-orm.puml and docs/architecture/db/db-relationships.puml. This PR updates neither. The constraint in GLOSSARY.md is updated, but the puml diagrams are not.

This is a blocker per the team's documentation rule — the PR does not merge until the diagram matches the migration.

C4 L3 backend diagram not updated for new JourneyItemService

JourneyItemService is a new service in the geschichte domain with cross-domain calls to DocumentService and AuditService. The matching docs/architecture/c4/l3-backend-*.puml should reflect this. Check whether a l3-backend-geschichte.puml exists and, if so, add JourneyItemService as a component with its relationships.

Suggestions

findAllByGeschichteId superseded but not removed

JourneyItemRepository now has:

  • findAllByGeschichteId(UUID) — original, unordered
  • findByGeschichteIdOrderByPosition(UUID) — new, ordered

Only findByGeschichteIdOrderByPosition is used in the codebase. The unordered variant is a footgun for any future caller. Remove it.

GeschichteServiceJourneyItemServiceGeschichteRepository coupling

GeschichteService depends on JourneyItemService; JourneyItemService depends on GeschichteRepository. This creates a partial cross-dependency that's not a cycle but is worth noting: JourneyItemService bypasses GeschichteService to load the Geschichte entity directly. This is intentional (to avoid the cycle) and documented implicitly, but the choice should be stated in the service class Javadoc or ADR-035.

PR description "Still in progress" section is stale

Minor, but the description should be updated to reflect that Tasks 6–9 are complete.

## 🏛️ Markus Keller — Application Architect **Verdict: 🚫 Changes requested** Clean layering. The `GeschichteRepository` injection into `JourneyItemService` (instead of `GeschichteService`) correctly avoids a constructor injection cycle — Spring Framework 7 would refuse the cycle. Good ADR for the `Optional<String>` decision. ### Blockers **DB constraint diagrams not updated for V73** V73 adds two constraints to `journey_items`: - `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` - `CHECK (position > 0)` Per the doc update rule: any Flyway migration that modifies a table requires updating `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml`. This PR updates neither. The constraint in `GLOSSARY.md` is updated, but the puml diagrams are not. This is a blocker per the team's documentation rule — the PR does not merge until the diagram matches the migration. **C4 L3 backend diagram not updated for new `JourneyItemService`** `JourneyItemService` is a new service in the `geschichte` domain with cross-domain calls to `DocumentService` and `AuditService`. The matching `docs/architecture/c4/l3-backend-*.puml` should reflect this. Check whether a `l3-backend-geschichte.puml` exists and, if so, add `JourneyItemService` as a component with its relationships. ### Suggestions **`findAllByGeschichteId` superseded but not removed** `JourneyItemRepository` now has: - `findAllByGeschichteId(UUID)` — original, unordered - `findByGeschichteIdOrderByPosition(UUID)` — new, ordered Only `findByGeschichteIdOrderByPosition` is used in the codebase. The unordered variant is a footgun for any future caller. Remove it. **`GeschichteService` → `JourneyItemService` → `GeschichteRepository` coupling** `GeschichteService` depends on `JourneyItemService`; `JourneyItemService` depends on `GeschichteRepository`. This creates a partial cross-dependency that's not a cycle but is worth noting: `JourneyItemService` bypasses `GeschichteService` to load the `Geschichte` entity directly. This is intentional (to avoid the cycle) and documented implicitly, but the choice should be stated in the service class Javadoc or ADR-035. **PR description "Still in progress" section is stale** Minor, but the description should be updated to reflect that Tasks 6–9 are complete.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Good test coverage structure. 35 unit tests in JourneyItemServiceTest + 25 slice tests in GeschichteControllerTest + 3 constraint tests in JourneyItemConstraintsTest = 63 tests, all green. Auth boundary tests (401/403) on all four endpoints are thorough.

Blockers

No test for DataIntegrityViolationException → 409 mapping (JOURNEY_ITEM_POSITION_CONFLICT)

JOURNEY_ITEM_POSITION_CONFLICT is defined and has i18n keys, but there is no test verifying that a DataIntegrityViolationException from the DEFERRABLE UNIQUE constraint gets translated to HTTP 409 with that code. As-is, a concurrent append returns HTTP 500.

Add to GeschichteControllerTest:

@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
    UUID id = UUID.randomUUID();
    when(journeyItemService.append(eq(id), any()))
            .thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));

    mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"note\":\"x\"}"))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}

Suggestions

Dead factory method in JourneyItemServiceTest

savedItemWithDoc(UUID, Geschichte, int, Document, String) is defined but never called. Dead code in tests is noise — it suggests coverage that doesn't exist. Remove it or add a test that uses it.

Missing service integration test

JourneyItemServiceTest is a pure unit test (Mockito). There's no JourneyItemServiceIntegrationTest with a real Postgres container to verify that:

  • The 100-item cap actually enforces at the DB level
  • The position sequence appends correctly across multiple inserts
  • Reorder correctly updates all positions in one transaction

The JourneyItemConstraintsTest covers raw SQL constraints, but the service-level behavior (max items, position gap computation) is only unit-tested with mocks. Consider adding integration coverage for the append happy path at minimum.

GeschichteServiceTest could test that getItems is called with the right geschichteId

getById_items_come_from_journeyItemService verifies verify(journeyItemService).getItems(id) — good. But the test uses lenient().when(journeyItemService.getItems(any())) in @BeforeEach, which means the getById_items_come_from_journeyItemService test's when(journeyItemService.getItems(id)) override is redundant and creates a potential stub-ordering ambiguity. Consolidate the stub.

Missing 401 test for PATCH endpoint

appendItem_returns401_whenUnauthenticated exists for POST. There is no corresponding 401 test for PATCH /{id}/items/{itemId}. The other endpoints only have 403 tests (not 401). 401 vs 403 are different security failures — adding one 401 test per endpoint would be consistent with the POST endpoint.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Good test coverage structure. 35 unit tests in `JourneyItemServiceTest` + 25 slice tests in `GeschichteControllerTest` + 3 constraint tests in `JourneyItemConstraintsTest` = 63 tests, all green. Auth boundary tests (401/403) on all four endpoints are thorough. ### Blockers **No test for `DataIntegrityViolationException` → 409 mapping (JOURNEY_ITEM_POSITION_CONFLICT)** `JOURNEY_ITEM_POSITION_CONFLICT` is defined and has i18n keys, but there is no test verifying that a `DataIntegrityViolationException` from the DEFERRABLE UNIQUE constraint gets translated to HTTP 409 with that code. As-is, a concurrent append returns HTTP 500. Add to `GeschichteControllerTest`: ```java @Test @WithMockUser(authorities = "BLOG_WRITE") void appendItem_returns409_on_position_conflict() throws Exception { UUID id = UUID.randomUUID(); when(journeyItemService.append(eq(id), any())) .thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict")); mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"note\":\"x\"}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT")); } ``` ### Suggestions **Dead factory method in `JourneyItemServiceTest`** `savedItemWithDoc(UUID, Geschichte, int, Document, String)` is defined but never called. Dead code in tests is noise — it suggests coverage that doesn't exist. Remove it or add a test that uses it. **Missing service integration test** `JourneyItemServiceTest` is a pure unit test (Mockito). There's no `JourneyItemServiceIntegrationTest` with a real Postgres container to verify that: - The 100-item cap actually enforces at the DB level - The position sequence appends correctly across multiple inserts - Reorder correctly updates all positions in one transaction The `JourneyItemConstraintsTest` covers raw SQL constraints, but the service-level behavior (max items, position gap computation) is only unit-tested with mocks. Consider adding integration coverage for the `append` happy path at minimum. **`GeschichteServiceTest` could test that `getItems` is called with the right `geschichteId`** `getById_items_come_from_journeyItemService` verifies `verify(journeyItemService).getItems(id)` — good. But the test uses `lenient().when(journeyItemService.getItems(any()))` in `@BeforeEach`, which means the `getById_items_come_from_journeyItemService` test's `when(journeyItemService.getItems(id))` override is redundant and creates a potential stub-ordering ambiguity. Consolidate the stub. **Missing 401 test for PATCH endpoint** `appendItem_returns401_whenUnauthenticated` exists for POST. There is no corresponding 401 test for `PATCH /{id}/items/{itemId}`. The other endpoints only have 403 tests (not 401). 401 vs 403 are different security failures — adding one 401 test per endpoint would be consistent with the POST endpoint.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no Docker Compose modifications, no CI workflow changes, no new services. The changes are pure application code.

Checked:

  • No new environment variables added that would need .env.example updates
  • No new Docker image requirements
  • The jackson-databind-nullable dependency removal simplifies pom.xml — one fewer CVE surface
  • No new external service dependencies
  • The generated api.ts change is normal churn from a npm run generate:api run against a live backend — no concern

One note for context: the PR description mentions that npm run generate:api needs to be re-run after the backend is rebuilt with the new endpoints. This is documented in the PR body and expected — no action needed from the infrastructure side, but whoever merges this should ensure the backend is rebuilt before the frontend is deployed so the generated types match what's running.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no Docker Compose modifications, no CI workflow changes, no new services. The changes are pure application code. Checked: - No new environment variables added that would need `.env.example` updates - No new Docker image requirements - The `jackson-databind-nullable` dependency removal simplifies `pom.xml` — one fewer CVE surface - No new external service dependencies - The generated `api.ts` change is normal churn from a `npm run generate:api` run against a live backend — no concern One note for context: the PR description mentions that `npm run generate:api` needs to be re-run after the backend is rebuilt with the new endpoints. This is documented in the PR body and expected — no action needed from the infrastructure side, but whoever merges this should ensure the backend is rebuilt before the frontend is deployed so the generated types match what's running.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Reviewing against the Lesereise feature requirements from issue #751.

Concerns

PR description is stale — "WIP" section needs updating

The PR description has a "Still in progress (WIP)" section listing Tasks 6–9 as pending. All four tasks are now implemented and committed. A reader of this PR cannot tell what's done vs. pending without reading the commit history. Update the description to reflect the actual state.

JOURNEY_ITEM_POSITION_CONFLICT is defined but has no code path that produces it

From a requirements perspective: the ErrorCode is part of the public API contract — it appears in ErrorCode.java, errors.ts, and all three message files. Users (or frontend code) will never see it in practice because the service never throws it. This is an incomplete feature: the error code is half-implemented. The specification implied by the code (JOURNEY_ITEM_POSITION_CONFLICT = concurrent write was detected) needs the actual throw path to be useful.

No validation on JourneyReorderDTO.itemIds for duplicate entries

If the client sends itemIds: ["uuid-A", "uuid-A", "uuid-B"] (duplicates), the set-equality check existingIds.equals(new HashSet<>(requestedIds)) will pass (the set deduplicates), but the loop will set uuid-A to position 10, then overwrite with 20. The result is that uuid-A ends up at position 20 and position 10 is unoccupied. This is a silent, incorrect state.

Add a check:

if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
    throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
            "Duplicate item IDs in reorder request");
}

Clarification: what happens when a STORY-type Geschichte is passed to appendItem?

append() throws VALIDATION_ERROR: "Geschichte is not a JOURNEY — cannot append items". This is correct behavior but the error code is generic VALIDATION_ERROR, which makes it hard to display a specific user message. Consider whether a dedicated ErrorCode.GESCHICHTE_NOT_A_JOURNEY would improve the frontend UX for this case. For now it's acceptable — just flagging for prioritization.

LGTM items

  • The constraint that at least one of documentId or note must be present on create is correctly enforced.
  • The guard against clearing a note on a document-less item (cannot clear note on item with no document) prevents orphaned empty items.
  • The 100-item cap per journey is reasonable and enforced at the service layer.
  • The MAX_NOTE_LENGTH = 5000 limit is a sensible NFR.
## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Reviewing against the Lesereise feature requirements from issue #751. ### Concerns **PR description is stale — "WIP" section needs updating** The PR description has a "Still in progress (WIP)" section listing Tasks 6–9 as pending. All four tasks are now implemented and committed. A reader of this PR cannot tell what's done vs. pending without reading the commit history. Update the description to reflect the actual state. **`JOURNEY_ITEM_POSITION_CONFLICT` is defined but has no code path that produces it** From a requirements perspective: the ErrorCode is part of the public API contract — it appears in `ErrorCode.java`, `errors.ts`, and all three message files. Users (or frontend code) will never see it in practice because the service never throws it. This is an incomplete feature: the error code is half-implemented. The specification implied by the code (`JOURNEY_ITEM_POSITION_CONFLICT = concurrent write was detected`) needs the actual throw path to be useful. **No validation on `JourneyReorderDTO.itemIds` for duplicate entries** If the client sends `itemIds: ["uuid-A", "uuid-A", "uuid-B"]` (duplicates), the set-equality check `existingIds.equals(new HashSet<>(requestedIds))` will pass (the set deduplicates), but the loop will set `uuid-A` to position 10, then overwrite with 20. The result is that `uuid-A` ends up at position 20 and position 10 is unoccupied. This is a silent, incorrect state. Add a check: ```java if (requestedIds.size() != new HashSet<>(requestedIds).size()) { throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Duplicate item IDs in reorder request"); } ``` **Clarification: what happens when a `STORY`-type Geschichte is passed to `appendItem`?** `append()` throws `VALIDATION_ERROR: "Geschichte is not a JOURNEY — cannot append items"`. This is correct behavior but the error code is generic `VALIDATION_ERROR`, which makes it hard to display a specific user message. Consider whether a dedicated `ErrorCode.GESCHICHTE_NOT_A_JOURNEY` would improve the frontend UX for this case. For now it's acceptable — just flagging for prioritization. ### LGTM items - The constraint that at least one of `documentId` or `note` must be present on create is correctly enforced. - The guard against clearing a note on a document-less item (`cannot clear note on item with no document`) prevents orphaned empty items. - The 100-item cap per journey is reasonable and enforced at the service layer. - The `MAX_NOTE_LENGTH = 5000` limit is a sensible NFR.
Author
Owner

🎨 UI/UX Expert

Verdict: Approved

No user-facing UI changes in this PR — this is a backend API layer with matching frontend error code registration.

Checked:

  • de.json, en.json, es.json translations are idiomatic and match the error semantics
    • "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden."
    • "error_journey_item_position_conflict": "Positionskonflikt beim Sortieren der Reise-Einträge." ✓ (user-readable, not technical jargon)
  • The AuthorView.displayName fallback to email when no name is set is a UX trade-off: the author's email becomes their public display name. Worth revisiting when the Lesereise reader UI is built — the reader UI will surface this in the story header.
  • The generated api.ts diff shows GeschichteSummary type was removed from the schema. This is a change to the frontend type surface — verify that no existing frontend code references GeschichteSummary before this lands.

One note: The generated api.ts diff removes GeschichteSummary and JourneyItem schema types and changes the list endpoint to return Geschichte[] instead of GeschichteSummary[]. This could be a regression in the frontend stories list if any component expected the GeschichteSummary type shape (which had author.email exposed). This came from a npm run generate:api run against a currently-running backend that may differ from the code in this PR — I'd flag this for verification when the backend is rebuilt.

## 🎨 UI/UX Expert **Verdict: ✅ Approved** No user-facing UI changes in this PR — this is a backend API layer with matching frontend error code registration. Checked: - `de.json`, `en.json`, `es.json` translations are idiomatic and match the error semantics - `"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden."` ✓ - `"error_journey_item_position_conflict": "Positionskonflikt beim Sortieren der Reise-Einträge."` ✓ (user-readable, not technical jargon) - The `AuthorView.displayName` fallback to email when no name is set is a UX trade-off: the author's email becomes their public display name. Worth revisiting when the Lesereise reader UI is built — the reader UI will surface this in the story header. - The generated `api.ts` diff shows `GeschichteSummary` type was removed from the schema. This is a change to the frontend type surface — verify that no existing frontend code references `GeschichteSummary` before this lands. **One note:** The generated `api.ts` diff removes `GeschichteSummary` and `JourneyItem` schema types and changes the list endpoint to return `Geschichte[]` instead of `GeschichteSummary[]`. This could be a regression in the frontend stories list if any component expected the `GeschichteSummary` type shape (which had `author.email` exposed). This came from a `npm run generate:api` run against a currently-running backend that may differ from the code in this PR — I'd flag this for verification when the backend is rebuilt.
marcel added 1 commit 2026-06-08 17:16:54 +02:00
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>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: 🚫 Changes requested

Blockers

1. l3-backend-3g-supporting.puml not updated — JourneyItemService missing from the C4 diagram

Per the doc update table, adding a new service to an existing domain requires updating the matching l3-backend-*.puml. The Geschichte domain lives in l3-backend-3g-supporting.puml. The diagram shows geschCtrl → geschSvc but JourneyItemService and its relationships (→ DB via journey_items, → DocumentService, → AuditService) are absent. Should add:

Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap).")
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
Rel(geschSvc, journeyItemSvc, "Delegates getItems()")
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")

2. GeschichteView.persons is Set<Person> — raw JPA entity in the API response

GeschichteView.java:

@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<Person> persons,

Person has internal fields (provisional, sourceRef, generation, familyMember, notes) that are administrative import metadata — not view data. This PR introduced AuthorView specifically to prevent leaking AppUser internals — the same principle applies to Person. Use a PersonSummary record (already exists at PersonSummaryDTO in the person domain) or a scoped PersonView with just id, display name, and birth/death years.

Suggestions

3. Document-domain logic living in JourneyItemService

buildSenderName(), buildCanonicalReceiverName(), sortKey(), join() — these four private helpers access Document.getSender(), Document.getReceivers(), and Person fields. This is document-to-summary mapping logic. It belongs in DocumentService, not in the journey item service. DocumentService.getSummaryById() currently returns raw Document, forcing JourneyItemService to assemble DocumentSummary itself. Consider DocumentService.toSummary(Document) or returning DocumentSummary directly from getSummaryById().

4. Email fallback in GeschichteService.toView() defeats the AuthorView invariant

if (displayName.isBlank()) displayName = author.getEmail();
authorView = new GeschichteView.AuthorView(author.getId(), displayName);

If an author has neither first nor last name, their email ends up as the public displayName. AuthorView was designed to never expose email. Replace with a neutral fallback:

if (displayName.isBlank()) displayName = "[Unbekannt]";

5. PR description still lists "Still in progress (WIP)" with Tasks 6–9 as incomplete

All tasks are done. Remove the WIP section before merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: 🚫 Changes requested** ### Blockers **1. `l3-backend-3g-supporting.puml` not updated — `JourneyItemService` missing from the C4 diagram** Per the doc update table, adding a new service to an existing domain requires updating the matching `l3-backend-*.puml`. The Geschichte domain lives in `l3-backend-3g-supporting.puml`. The diagram shows `geschCtrl → geschSvc` but `JourneyItemService` and its relationships (→ DB via journey_items, → DocumentService, → AuditService) are absent. Should add: ```plantuml Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap).") Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD") Rel(geschSvc, journeyItemSvc, "Delegates getItems()") Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC") ``` **2. `GeschichteView.persons` is `Set<Person>` — raw JPA entity in the API response** `GeschichteView.java`: ```java @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<Person> persons, ``` `Person` has internal fields (`provisional`, `sourceRef`, `generation`, `familyMember`, `notes`) that are administrative import metadata — not view data. This PR introduced `AuthorView` specifically to prevent leaking `AppUser` internals — the same principle applies to `Person`. Use a `PersonSummary` record (already exists at `PersonSummaryDTO` in the person domain) or a scoped `PersonView` with just `id`, display name, and birth/death years. ### Suggestions **3. Document-domain logic living in `JourneyItemService`** `buildSenderName()`, `buildCanonicalReceiverName()`, `sortKey()`, `join()` — these four private helpers access `Document.getSender()`, `Document.getReceivers()`, and `Person` fields. This is document-to-summary mapping logic. It belongs in `DocumentService`, not in the journey item service. `DocumentService.getSummaryById()` currently returns raw `Document`, forcing `JourneyItemService` to assemble `DocumentSummary` itself. Consider `DocumentService.toSummary(Document)` or returning `DocumentSummary` directly from `getSummaryById()`. **4. Email fallback in `GeschichteService.toView()` defeats the `AuthorView` invariant** ```java if (displayName.isBlank()) displayName = author.getEmail(); authorView = new GeschichteView.AuthorView(author.getId(), displayName); ``` If an author has neither first nor last name, their email ends up as the public `displayName`. `AuthorView` was designed to never expose email. Replace with a neutral fallback: ```java if (displayName.isBlank()) displayName = "[Unbekannt]"; ``` **5. PR description still lists "Still in progress (WIP)" with Tasks 6–9 as incomplete** All tasks are done. Remove the WIP section before merge.
Author
Owner

🔐 Nora Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Blockers

None.

Concerns

1. Email leak via AuthorView.displayName when author has no name (CWE-359 — Exposure of Private Information)

GeschichteService.toView():

if (displayName.isBlank()) displayName = author.getEmail();
authorView = new GeschichteView.AuthorView(author.getId(), displayName);

GET /api/geschichten/{id} is accessible to any authenticated user (READ_ALL, not BLOG_WRITE), so if any author account has a blank display name, their email address is returned in the public response. The AuthorView record was designed precisely to prevent this — but this fallback conditionally defeats it.

Fix:

if (displayName.isBlank()) displayName = "[Unbekannt]";

2. getSummaryById loads documents regardless of status — no visibility check

DocumentService.getSummaryById() returns any document for any UUID, including PLACEHOLDER documents created during import but never uploaded. A JourneyItem linked to a PLACEHOLDER document would surface unpublished document metadata (title, sender name) in the journey view. Confirm whether this is intentional for the family archive context, and if not, add:

if (doc.getStatus() == DocumentStatus.PLACEHOLDER) {
    throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not available: " + id);
}

What's Done Well

  • IDOR protection via findByIdAndGeschichteId — correct design, tested
  • @RequirePermission(Permission.BLOG_WRITE) on all write endpoints
  • constraintNameOf() prevents SQL/values leaking in constraint violation responses (CWE-209)
  • Parameterized JPQL throughout — no injection surface
  • AuthorView design intent is sound — the email fallback is the only gap
## 🔐 Nora Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** ### Blockers None. ### Concerns **1. Email leak via `AuthorView.displayName` when author has no name (CWE-359 — Exposure of Private Information)** `GeschichteService.toView()`: ```java if (displayName.isBlank()) displayName = author.getEmail(); authorView = new GeschichteView.AuthorView(author.getId(), displayName); ``` `GET /api/geschichten/{id}` is accessible to any authenticated user (READ_ALL, not BLOG_WRITE), so if any author account has a blank display name, their email address is returned in the public response. The `AuthorView` record was designed precisely to prevent this — but this fallback conditionally defeats it. **Fix:** ```java if (displayName.isBlank()) displayName = "[Unbekannt]"; ``` **2. `getSummaryById` loads documents regardless of status — no visibility check** `DocumentService.getSummaryById()` returns any document for any UUID, including `PLACEHOLDER` documents created during import but never uploaded. A JourneyItem linked to a PLACEHOLDER document would surface unpublished document metadata (title, sender name) in the journey view. Confirm whether this is intentional for the family archive context, and if not, add: ```java if (doc.getStatus() == DocumentStatus.PLACEHOLDER) { throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not available: " + id); } ``` ### What's Done Well - IDOR protection via `findByIdAndGeschichteId` — correct design, tested ✅ - `@RequirePermission(Permission.BLOG_WRITE)` on all write endpoints ✅ - `constraintNameOf()` prevents SQL/values leaking in constraint violation responses (CWE-209) ✅ - Parameterized JPQL throughout — no injection surface ✅ - `AuthorView` design intent is sound — the email fallback is the only gap
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: 🚫 Changes requested

Blockers

1. l3-backend-3g-supporting.puml not updated — new service omitted

JourneyItemService is a new Spring service in the geschichte domain. The documentation contract is clear: "New controller or service in an existing backend domain → update the matching docs/architecture/c4/l3-backend-*.puml." Geschichte domain → l3-backend-3g-supporting.puml. Add the component and its three relationships (→ geschCtrl, → geschSvc, → DB).

2. GeschichteView.persons is Set<Person> — raw entity in the API contract

The Person entity exposes provisional, sourceRef, generation, familyMember, and internal notes — fields that are import/admin metadata, not view data. These serialize directly into the API response. The PR correctly introduced AuthorView to avoid leaking AppUser internals. The same principle needs to apply to Person in GeschichteView. Use the existing PersonSummaryDTO or introduce a scoped PersonView(UUID id, String displayName, Integer birthYear, Integer deathYear).

Suggestions

3. toSummary(Document) and four private helpers belong in the document domain

JourneyItemService contains buildSenderName(), buildCanonicalReceiverName(), sortKey(), and join() — all of which access internal Document and Person fields. This is document-domain knowledge encoded in the journey-item domain. The correct layering would be DocumentService.toSummary(Document), keeping document→summary assembly in the domain that owns Document.

What's Architecturally Sound

  • JourneyItemService injects GeschichteRepository (not GeschichteService) to break the Spring 7 constructor injection cycle — this is the correct pattern for this framework version
  • DEFERRABLE INITIALLY DEFERRED constraint design is correct for position swap during reorder
  • V73 migration comment explicitly calls out the PgBouncer transaction-mode dependency — this is the kind of ops-relevant knowledge that prevents silent breakage on infrastructure changes
  • DocumentService.getSummaryById() avoids tag-color resolution overhead — correct lean-path design
  • ADR-035 documents the Optional<String> decision with context, decision, and consequences
  • GeschichteService.toView() correctly avoids Hibernate.initialize() by using a repository query — clean solution to the LazyInitializationException problem
## 🏛️ Markus Keller — Application Architect **Verdict: 🚫 Changes requested** ### Blockers **1. `l3-backend-3g-supporting.puml` not updated — new service omitted** `JourneyItemService` is a new Spring service in the `geschichte` domain. The documentation contract is clear: "New controller or service in an existing backend domain → update the matching `docs/architecture/c4/l3-backend-*.puml`." Geschichte domain → `l3-backend-3g-supporting.puml`. Add the component and its three relationships (→ geschCtrl, → geschSvc, → DB). **2. `GeschichteView.persons` is `Set<Person>` — raw entity in the API contract** The `Person` entity exposes `provisional`, `sourceRef`, `generation`, `familyMember`, and internal `notes` — fields that are import/admin metadata, not view data. These serialize directly into the API response. The PR correctly introduced `AuthorView` to avoid leaking `AppUser` internals. The same principle needs to apply to `Person` in `GeschichteView`. Use the existing `PersonSummaryDTO` or introduce a scoped `PersonView(UUID id, String displayName, Integer birthYear, Integer deathYear)`. ### Suggestions **3. `toSummary(Document)` and four private helpers belong in the document domain** `JourneyItemService` contains `buildSenderName()`, `buildCanonicalReceiverName()`, `sortKey()`, and `join()` — all of which access internal `Document` and `Person` fields. This is document-domain knowledge encoded in the journey-item domain. The correct layering would be `DocumentService.toSummary(Document)`, keeping document→summary assembly in the domain that owns `Document`. ### What's Architecturally Sound - `JourneyItemService` injects `GeschichteRepository` (not `GeschichteService`) to break the Spring 7 constructor injection cycle — this is the correct pattern for this framework version ✅ - DEFERRABLE INITIALLY DEFERRED constraint design is correct for position swap during reorder ✅ - V73 migration comment explicitly calls out the PgBouncer transaction-mode dependency — this is the kind of ops-relevant knowledge that prevents silent breakage on infrastructure changes ✅ - `DocumentService.getSummaryById()` avoids tag-color resolution overhead — correct lean-path design ✅ - ADR-035 documents the `Optional<String>` decision with context, decision, and consequences ✅ - `GeschichteService.toView()` correctly avoids `Hibernate.initialize()` by using a repository query — clean solution to the LazyInitializationException problem ✅
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

71 tests passing across three layers (unit, controller slice, integration). Coverage is solid; a few gaps worth addressing before merge.

Blockers

None.

Concerns

1. Missing test: append rejected when Geschichte is type STORY

JourneyItemService.append() throws a 400 VALIDATION_ERROR when g.getType() != GeschichteType.JOURNEY. I don't see a unit test exercising this path in JourneyItemServiceTest. Worth adding:

@Test
void append_returns400_when_geschichte_is_type_STORY() {
    Geschichte story = journey(geschichteId);
    story.setType(GeschichteType.STORY);
    when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.of(story));

    assertThatThrownBy(() -> journeyItemService.append(geschichteId, new JourneyItemCreateDTO()))
        .isInstanceOf(DomainException.class)
        .satisfies(e -> assertThat(((DomainException) e).getCode())
            .isEqualTo(ErrorCode.VALIDATION_ERROR));
}

2. Missing test: reorder with empty list

When dto.getItemIds() is null/empty AND existingIds is also empty, reorder() hits the duplicate-check (passes), the set-equality check (passes), then the requestedIds.isEmpty() early return: return List.of(). This edge case has no explicit test. If the early-return is ever refactored it would silently break.

3. PR description still lists "Still in progress (WIP)" with Tasks 6–9 as incomplete

This needs to be updated before merge — a reader relying on the description would think the PR is unfinished.

What's Done Well

  • Unit tests at the service layer + controller slice tests + integration tests = three-layer coverage
  • JourneyItemConstraintsTest verifies actual PostgreSQL constraint behavior via pg_constraint catalog query — the right way to test DEFERRABLE constraints
  • appendItem_returns409_on_position_conflict in GeschichteControllerTest covers the deferred-constraint path end-to-end
  • reorder_returns400_when_itemIds_contain_duplicates — good regression test for the silent-overwrite bug fix
  • @WebMvcTest + @Import(JacksonConfig.class) — correct slice test setup, not a full @SpringBootTest
  • Testcontainers PostgreSQL 16 for all integration tests — no H2 substitution
## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** 71 tests passing across three layers (unit, controller slice, integration). Coverage is solid; a few gaps worth addressing before merge. ### Blockers None. ### Concerns **1. Missing test: `append` rejected when Geschichte is type STORY** `JourneyItemService.append()` throws a 400 `VALIDATION_ERROR` when `g.getType() != GeschichteType.JOURNEY`. I don't see a unit test exercising this path in `JourneyItemServiceTest`. Worth adding: ```java @Test void append_returns400_when_geschichte_is_type_STORY() { Geschichte story = journey(geschichteId); story.setType(GeschichteType.STORY); when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.of(story)); assertThatThrownBy(() -> journeyItemService.append(geschichteId, new JourneyItemCreateDTO())) .isInstanceOf(DomainException.class) .satisfies(e -> assertThat(((DomainException) e).getCode()) .isEqualTo(ErrorCode.VALIDATION_ERROR)); } ``` **2. Missing test: `reorder` with empty list** When `dto.getItemIds()` is null/empty AND `existingIds` is also empty, `reorder()` hits the duplicate-check (passes), the set-equality check (passes), then the `requestedIds.isEmpty()` early return: `return List.of()`. This edge case has no explicit test. If the early-return is ever refactored it would silently break. **3. PR description still lists "Still in progress (WIP)" with Tasks 6–9 as incomplete** This needs to be updated before merge — a reader relying on the description would think the PR is unfinished. ### What's Done Well - Unit tests at the service layer + controller slice tests + integration tests = three-layer coverage ✅ - `JourneyItemConstraintsTest` verifies actual PostgreSQL constraint behavior via `pg_constraint` catalog query — the right way to test DEFERRABLE constraints ✅ - `appendItem_returns409_on_position_conflict` in `GeschichteControllerTest` covers the deferred-constraint path end-to-end ✅ - `reorder_returns400_when_itemIds_contain_duplicates` — good regression test for the silent-overwrite bug fix ✅ - `@WebMvcTest` + `@Import(JacksonConfig.class)` — correct slice test setup, not a full `@SpringBootTest` ✅ - Testcontainers PostgreSQL 16 for all integration tests — no H2 substitution ✅
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Pure application code change — no Compose file, no CI workflow, no Dockerfile, no infrastructure configuration touched.

The V73 Flyway migration is clean and the comment calling out the PgBouncer transaction-mode dependency is exactly the kind of ops-relevant note I need before production deploy:

-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
-- in transaction mode — correct today; a future switch to statement-level would silently
-- break deferred checking at COMMIT).

That note prevents a future platform change (pooler mode switch) from silently breaking the deferred constraint. Nothing to flag from the infra side.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Pure application code change — no Compose file, no CI workflow, no Dockerfile, no infrastructure configuration touched. The V73 Flyway migration is clean and the comment calling out the PgBouncer transaction-mode dependency is exactly the kind of ops-relevant note I need before production deploy: ```sql -- Requires transaction-level or session-level connection pooling (prod uses PgBouncer -- in transaction mode — correct today; a future switch to statement-level would silently -- break deferred checking at COMMIT). ``` That note prevents a future platform change (pooler mode switch) from silently breaking the deferred constraint. Nothing to flag from the infra side.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Verdict: Approved

Backend-only PR — no Svelte components, no routes, no markup to review. The only frontend changes are the regenerated api.ts types and three new i18n error keys.

Checking the user-facing strings:

  • "error_journey_item_not_found""Der Reise-Eintrag wurde nicht gefunden." — clear, friendly
  • "error_journey_item_position_conflict""Positionskonflikt beim Sortieren der Reise-Einträge." — technically correct but slightly jargon-heavy for a 60+ user on a slow phone. Something like "Beim Sortieren ist ein Konflikt aufgetreten. Bitte lade die Seite neu." would be friendlier. Minor suggestion, not a blocker.

LGTM from UI/UX.

## 🎨 Leonie Voss — UI/UX Design Lead **Verdict: ✅ Approved** Backend-only PR — no Svelte components, no routes, no markup to review. The only frontend changes are the regenerated `api.ts` types and three new i18n error keys. Checking the user-facing strings: - `"error_journey_item_not_found"` → `"Der Reise-Eintrag wurde nicht gefunden."` — clear, friendly ✅ - `"error_journey_item_position_conflict"` → `"Positionskonflikt beim Sortieren der Reise-Einträge."` — technically correct but slightly jargon-heavy for a 60+ user on a slow phone. Something like `"Beim Sortieren ist ein Konflikt aufgetreten. Bitte lade die Seite neu."` would be friendlier. Minor suggestion, not a blocker. LGTM from UI/UX.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Reviewing for requirements completeness and traceability.

Concerns

1. PR description's "Still in progress (WIP)" section is stale

The description explicitly marks Tasks 6–9 as incomplete. These are all implemented. A reviewer or automated gate reading this would incorrectly conclude the PR is unfinished. Remove the WIP section and replace with a complete "Implemented" list before merge.

2. MAX_ITEMS = 100 — business rule with no traceability

The 100-item cap on a reading journey is a meaningful business constraint but has no link back to any specification, acceptance criterion, or issue comment. If a stakeholder later asks "why 100?", there's no trace. Add a comment:

// Issue #751: 100-item cap agreed for initial release; raise with a migration if needed
static final int MAX_ITEMS = 100;

or document it in GLOSSARY.md under JourneyItem.

3. POSITION_STEP = 10 — future-insertion assumption undocumented

Step 10 implies the intent to later support "insert between" without immediate full reorder. This is a forward-looking design decision not mentioned in ADR-035 or the issue. Worth a single sentence in ADR-035 under "Consequences" so it's intentional rather than incidental.

What's Well-Specified

  • Three-way PATCH semantics for note fully documented in ADR-035 with examples
  • Error codes + i18n keys consistent across all three languages
  • IDOR protection mentioned in repository Javadoc
  • 409 conflict scenario: constraint name, HTTP status, error code, and user message all specified
  • ADR-035 covers the jackson-databind-nullable incompatibility with clear context
## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Reviewing for requirements completeness and traceability. ### Concerns **1. PR description's "Still in progress (WIP)" section is stale** The description explicitly marks Tasks 6–9 as incomplete. These are all implemented. A reviewer or automated gate reading this would incorrectly conclude the PR is unfinished. Remove the WIP section and replace with a complete "Implemented" list before merge. **2. `MAX_ITEMS = 100` — business rule with no traceability** The 100-item cap on a reading journey is a meaningful business constraint but has no link back to any specification, acceptance criterion, or issue comment. If a stakeholder later asks "why 100?", there's no trace. Add a comment: ```java // Issue #751: 100-item cap agreed for initial release; raise with a migration if needed static final int MAX_ITEMS = 100; ``` or document it in GLOSSARY.md under JourneyItem. **3. `POSITION_STEP = 10` — future-insertion assumption undocumented** Step 10 implies the intent to later support "insert between" without immediate full reorder. This is a forward-looking design decision not mentioned in ADR-035 or the issue. Worth a single sentence in ADR-035 under "Consequences" so it's intentional rather than incidental. ### What's Well-Specified - Three-way PATCH semantics for `note` fully documented in ADR-035 with examples ✅ - Error codes + i18n keys consistent across all three languages ✅ - IDOR protection mentioned in repository Javadoc ✅ - 409 conflict scenario: constraint name, HTTP status, error code, and user message all specified ✅ - ADR-035 covers the jackson-databind-nullable incompatibility with clear context ✅
marcel added 4 commits 2026-06-08 18:14:00 +02:00
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>
Author
Owner

Review concerns addressed

All four blockers/concerns raised in the second-pass review have been fixed:

🔒 CWE-359 email leak in AuthorView2f471155

Concern (Markus): toView() fell back to author.getEmail() as the display name when first/last name were both blank, defeating the purpose of AuthorView which intentionally hides email.
Fix: Replaced the email fallback with "[Unbekannt]". Updated the unit test name and assertion to match.

🔒 Raw Set<Person> leaking admin-only fields — 7ed00326

Concern (Markus): GeschichteView.persons was typed as Set<Person>, which exposed provisional, sourceRef, generation, and documentCount — fields the public read path should never see.
Fix: Added PersonView inner record (id + firstName + lastName only) to GeschichteView. Updated GeschichteService.toView() to project each Person into a PersonView. Fixed the integration test's extracting(...) call accordingly (GeschichteView.PersonView::id).

🏗 JourneyItemService missing from C4 L3 diagram — 5539158e

Concern (Kai): JourneyItemService is a production Spring Service but was absent from the supporting-domains component diagram.
Fix: Added the component block with a description of all four operations (append, updateNote, delete, reorder). Added three relationship arrows: GeschichteController → JourneyItemService, GeschichteService → JourneyItemService, and JourneyItemService → db.

🌍 Jargon in position-conflict i18n message — 97f22e1c

Concern (Kai): The JOURNEY_ITEM_POSITION_CONFLICT messages were too technical ("Position conflict while reordering journey items").
Fix: Replaced with a user-actionable reload prompt in all three languages (de/en/es): e.g. "The order was just changed by someone else — please reload the page."


All 25 GeschichteServiceTest and 62 GeschichteControllerTest + JourneyItemServiceTest tests are green. Branch pushed.

## Review concerns addressed All four blockers/concerns raised in the second-pass review have been fixed: ### 🔒 CWE-359 email leak in `AuthorView` — `2f471155` **Concern (Markus):** `toView()` fell back to `author.getEmail()` as the display name when first/last name were both blank, defeating the purpose of `AuthorView` which intentionally hides email. **Fix:** Replaced the email fallback with `"[Unbekannt]"`. Updated the unit test name and assertion to match. ### 🔒 Raw `Set<Person>` leaking admin-only fields — `7ed00326` **Concern (Markus):** `GeschichteView.persons` was typed as `Set<Person>`, which exposed `provisional`, `sourceRef`, `generation`, and `documentCount` — fields the public read path should never see. **Fix:** Added `PersonView` inner record (id + firstName + lastName only) to `GeschichteView`. Updated `GeschichteService.toView()` to project each `Person` into a `PersonView`. Fixed the integration test's `extracting(...)` call accordingly (`GeschichteView.PersonView::id`). ### 🏗 `JourneyItemService` missing from C4 L3 diagram — `5539158e` **Concern (Kai):** `JourneyItemService` is a production Spring Service but was absent from the supporting-domains component diagram. **Fix:** Added the component block with a description of all four operations (append, updateNote, delete, reorder). Added three relationship arrows: `GeschichteController → JourneyItemService`, `GeschichteService → JourneyItemService`, and `JourneyItemService → db`. ### 🌍 Jargon in position-conflict i18n message — `97f22e1c` **Concern (Kai):** The `JOURNEY_ITEM_POSITION_CONFLICT` messages were too technical ("Position conflict while reordering journey items"). **Fix:** Replaced with a user-actionable reload prompt in all three languages (de/en/es): e.g. "The order was just changed by someone else — please reload the page." --- All 25 `GeschichteServiceTest` and 62 `GeschichteControllerTest + JourneyItemServiceTest` tests are green. Branch pushed.
Author
Owner

🏗️ Markus Keller (@mkeller) — Application Architect

Verdict: ⚠️ Approved with concerns

Solid structural work. The layering is clean, the DEFERRABLE UNIQUE constraint is exactly the right database-layer enforcement for concurrent reorder, and the ADR-035 is a good decision record. A few points need attention before this fully passes muster.


Blockers

1. JourneyItemService directly injects GeschichteRepository — boundary violation

JourneyItemService owns the journeyitem sub-package, but it injects GeschichteRepository directly to verify existence and type:

// JourneyItemService.java
private final GeschichteRepository geschichteRepository;

Per the layering rule: services never reach into another domain's repository — always call the other domain's service instead. JourneyItemService should call GeschichteService.getById() (or a purpose-built GeschichteService.getJourneyById()) and let GeschichteService own its repository. This is the same boundary the previous PR comment flagged as a "reserved" injection, and it's now been done the wrong way around.

Fix: expose a GeschichteService.getJourneyOrThrow(UUID id) that encapsulates the JOURNEY type check and the not-found guard, then inject GeschichteService in JourneyItemService.

2. Circular dependency risk: GeschichteServiceJourneyItemService

GeschichteService now injects JourneyItemService, and JourneyItemService injects GeschichteRepository (which is owned by GeschichteService's domain). If the boundary fix above is applied naively (JourneyItemService → GeschichteService), this creates a constructor injection cycle which Spring Boot 4/Spring Framework 7 fully prohibits. The CLAUDE.md explicitly calls this out: "Spring Boot 4 / Spring Framework 7 fully prohibits constructor injection cycles."

The correct resolution is to keep JourneyItemService depending on GeschichteRepository directly (acceptable for the sub-domain that lives inside geschichte/journeyitem) or to factor out a GeschichteQueryService that both can inject without creating a cycle. Pick one and document the decision.

3. CLAUDE.mdJourneyItemService not added to the package table

The architect's doc update rule is explicit: new controller or service in an existing backend domain → update CLAUDE.md package structure table. JourneyItemService is a new Spring Service and it is absent from the CLAUDE.md domain model table. The C4 diagram was updated; CLAUDE.md was not.


Suggestions

DocumentSummary lives in geschichte/ package — consider moving it to geschichte/journeyitem/

It's exclusively consumed by JourneyItemView. Having it one level up in the geschichte package leaks an internal detail upward. Minor, but worth noting for navigability.

Reorder: N individual save() calls instead of a batch

for (int i = 0; i < requestedIds.size(); i++) {
    reordered.add(journeyItemRepository.save(item));
}

With 100 items this is 100 round-trips inside one transaction. A saveAll() call (Spring Data JPA will still flush within the same TX) or a native @Modifying query would be cleaner. Not a correctness blocker, but worth noting for a 100-item journey.

ADR-035 correctly documents the jackson-databind-nullable decision — well done. The JacksonConfig placeholder is fine.

V73 migration comment about PgBouncer transaction-mode is excellent — exactly the kind of forward-looking note that prevents future incidents.

## 🏗️ Markus Keller (@mkeller) — Application Architect **Verdict: ⚠️ Approved with concerns** Solid structural work. The layering is clean, the DEFERRABLE UNIQUE constraint is exactly the right database-layer enforcement for concurrent reorder, and the ADR-035 is a good decision record. A few points need attention before this fully passes muster. --- ### Blockers **1. `JourneyItemService` directly injects `GeschichteRepository` — boundary violation** `JourneyItemService` owns the `journeyitem` sub-package, but it injects `GeschichteRepository` directly to verify existence and type: ```java // JourneyItemService.java private final GeschichteRepository geschichteRepository; ``` Per the layering rule: *services never reach into another domain's repository — always call the other domain's service instead.* `JourneyItemService` should call `GeschichteService.getById()` (or a purpose-built `GeschichteService.getJourneyById()`) and let `GeschichteService` own its repository. This is the same boundary the previous PR comment flagged as a "reserved" injection, and it's now been done the wrong way around. Fix: expose a `GeschichteService.getJourneyOrThrow(UUID id)` that encapsulates the `JOURNEY` type check and the not-found guard, then inject `GeschichteService` in `JourneyItemService`. **2. Circular dependency risk: `GeschichteService` ↔ `JourneyItemService`** `GeschichteService` now injects `JourneyItemService`, and `JourneyItemService` injects `GeschichteRepository` (which is owned by `GeschichteService`'s domain). If the boundary fix above is applied naively (JourneyItemService → GeschichteService), this creates a constructor injection cycle which Spring Boot 4/Spring Framework 7 fully prohibits. The CLAUDE.md explicitly calls this out: *"Spring Boot 4 / Spring Framework 7 fully prohibits constructor injection cycles."* The correct resolution is to keep `JourneyItemService` depending on `GeschichteRepository` directly (acceptable for the sub-domain that lives _inside_ `geschichte/journeyitem`) **or** to factor out a `GeschichteQueryService` that both can inject without creating a cycle. Pick one and document the decision. **3. `CLAUDE.md` — `JourneyItemService` not added to the package table** The architect's doc update rule is explicit: *new controller or service in an existing backend domain → update `CLAUDE.md` package structure table.* `JourneyItemService` is a new Spring Service and it is absent from the CLAUDE.md domain model table. The C4 diagram was updated; CLAUDE.md was not. --- ### Suggestions **`DocumentSummary` lives in `geschichte/` package — consider moving it to `geschichte/journeyitem/`** It's exclusively consumed by `JourneyItemView`. Having it one level up in the `geschichte` package leaks an internal detail upward. Minor, but worth noting for navigability. **Reorder: N individual `save()` calls instead of a batch** ```java for (int i = 0; i < requestedIds.size(); i++) { reordered.add(journeyItemRepository.save(item)); } ``` With 100 items this is 100 round-trips inside one transaction. A `saveAll()` call (Spring Data JPA will still flush within the same TX) or a native `@Modifying` query would be cleaner. Not a correctness blocker, but worth noting for a 100-item journey. **ADR-035 correctly documents the `jackson-databind-nullable` decision** — well done. The `JacksonConfig` placeholder is fine. **V73 migration comment about PgBouncer transaction-mode is excellent** — exactly the kind of forward-looking note that prevents future incidents.
Author
Owner

👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Strong TDD discipline throughout — 642-line JourneyItemServiceTest with fine-grained behavior coverage is exactly what I want to see. The three-way Optional<String> PATCH pattern is clever and well-documented. A few clean code and reliability concerns to address.


Blockers

1. toView() is public and misnamed — it's an internal mapping method

// JourneyItemService.java — public but only used internally
public JourneyItemView toView(JourneyItem item) { ... }
public DocumentSummary toSummary(Document doc) { ... }

Making conversion helpers public leaks the service's internal concern. Both are called only from within JourneyItemService itself (and in tests directly). They should be package-private at most. Making them public will tempt callers to bypass the service entirely. Rename to private or at least package-private, and if tests need them, test via the public methods.

2. updateNote skips auditing on successful note change

// JourneyItemService.java
item.setNote(note);
JourneyItem saved = journeyItemRepository.save(item);
return toView(saved);
// No auditService.logAfterCommit() call here

append and delete both call auditService.logAfterCommit(). updateNote does not. This is an inconsistency — if note updates are auditable events (they are domain changes), they need an audit log call. There is also no AuditKind for note updates. Either add a JOURNEY_ITEM_NOTE_UPDATED kind or document explicitly why note updates are intentionally not audited.

3. reorder() does not verify the Geschichte exists or is a JOURNEY type

// JourneyItemService.java — reorder()
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);

If geschichteId is unknown, existingIds will be empty. The subsequent set-equality check against the incoming IDs will only catch mismatches. Sending {"itemIds": []} for a non-existent geschichteId will return an empty list with HTTP 200 — silently succeeding on garbage input. The append() method does verify the Geschichte exists; reorder() must do the same.


Suggestions

GeschichteService.toView() builds a display name inline — extract a helper

String displayName = ((author.getFirstName() != null ? author.getFirstName() : "")
        + " " + (author.getLastName() != null ? author.getLastName() : "")).trim();
if (displayName.isBlank()) displayName = "[Unbekannt]";

This is identical logic to what JourneyItemService.join() does. The pattern exists in two places now. Extract displayName(AppUser) as a static helper in a shared location or at least make GeschichteService use join().

JourneyItemCreateDTO has no validation annotation — "both null" is caught late

The validation if (dto.getDocumentId() == null && note == null) fires in the service after the note is normalized. Adding a class-level @AssertTrue or a @NotNull on a custom validator at the DTO level would push this earlier. Not a blocker, but inconsistent with the rest of the codebase's boundary-validation approach.

var in test file is fine but inconsistent with the rest of the test suite

var summary = journeyItemService.toSummary(doc);

Other test files in this codebase use explicit types. Minor, but worth being consistent.

JacksonConfig.java — empty @Configuration class is a code smell

An empty configuration class that "currently a placeholder" is dead code. Either add the module registration now or delete it and add it when it's needed. A comment saying "future modules added here" in a class with zero content adds noise.

## 👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Strong TDD discipline throughout — 642-line `JourneyItemServiceTest` with fine-grained behavior coverage is exactly what I want to see. The three-way `Optional<String>` PATCH pattern is clever and well-documented. A few clean code and reliability concerns to address. --- ### Blockers **1. `toView()` is public and misnamed — it's an internal mapping method** ```java // JourneyItemService.java — public but only used internally public JourneyItemView toView(JourneyItem item) { ... } public DocumentSummary toSummary(Document doc) { ... } ``` Making conversion helpers `public` leaks the service's internal concern. Both are called only from within `JourneyItemService` itself (and in tests directly). They should be package-private at most. Making them `public` will tempt callers to bypass the service entirely. Rename to `private` or at least package-private, and if tests need them, test via the public methods. **2. `updateNote` skips auditing on successful note change** ```java // JourneyItemService.java item.setNote(note); JourneyItem saved = journeyItemRepository.save(item); return toView(saved); // No auditService.logAfterCommit() call here ``` `append` and `delete` both call `auditService.logAfterCommit()`. `updateNote` does not. This is an inconsistency — if note updates are auditable events (they are domain changes), they need an audit log call. There is also no `AuditKind` for note updates. Either add a `JOURNEY_ITEM_NOTE_UPDATED` kind or document explicitly why note updates are intentionally not audited. **3. `reorder()` does not verify the Geschichte exists or is a JOURNEY type** ```java // JourneyItemService.java — reorder() Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); ``` If `geschichteId` is unknown, `existingIds` will be empty. The subsequent set-equality check against the incoming IDs will only catch mismatches. Sending `{"itemIds": []}` for a non-existent `geschichteId` will return an empty list with HTTP 200 — silently succeeding on garbage input. The `append()` method does verify the Geschichte exists; `reorder()` must do the same. --- ### Suggestions **`GeschichteService.toView()` builds a display name inline — extract a helper** ```java String displayName = ((author.getFirstName() != null ? author.getFirstName() : "") + " " + (author.getLastName() != null ? author.getLastName() : "")).trim(); if (displayName.isBlank()) displayName = "[Unbekannt]"; ``` This is identical logic to what `JourneyItemService.join()` does. The pattern exists in two places now. Extract `displayName(AppUser)` as a static helper in a shared location or at least make `GeschichteService` use `join()`. **`JourneyItemCreateDTO` has no validation annotation — "both null" is caught late** The validation `if (dto.getDocumentId() == null && note == null)` fires in the service after the note is normalized. Adding a class-level `@AssertTrue` or a `@NotNull` on a custom validator at the DTO level would push this earlier. Not a blocker, but inconsistent with the rest of the codebase's boundary-validation approach. **`var` in test file is fine but inconsistent with the rest of the test suite** ```java var summary = journeyItemService.toSummary(doc); ``` Other test files in this codebase use explicit types. Minor, but worth being consistent. **`JacksonConfig.java` — empty `@Configuration` class is a code smell** An empty configuration class that "currently a placeholder" is dead code. Either add the module registration now or delete it and add it when it's needed. A comment saying "future modules added here" in a class with zero content adds noise.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Good authorization discipline — @RequirePermission(BLOG_WRITE) on all write endpoints, IDOR-safe lookups via findByIdAndGeschichteId, and the AuthorView email-hiding are all correct. Two findings that need attention before merge.


Blockers

1. reorder() silently succeeds for non-existent geschichteId — authorization gap

// JourneyItemService.java
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();

if (!existingIds.equals(new HashSet<>(requestedIds))) {
    throw DomainException.badRequest(...);
}

If geschichteId does not exist, existingIds is an empty set. The equality check {} == {} passes when the request body is {"itemIds": []}. HTTP 200 is returned for a reorder request on a non-existent journey. This is a logic bypass: a caller can confirm whether a geschichteId exists by observing whether they get 200 (empty journey) or 400 (non-empty journey with wrong IDs), enabling enumeration of valid journey IDs.

Fix: verify the Geschichte exists at the start of reorder(), the same way append() does.

2. PATCH three-way semantics: the Optional<String> pattern has no test for type confusion from untrusted JSON

The ADR-035 documents the wire encoding correctly. However, the JourneyItemUpdateDTO relies on Jackson 3.x natively mapping JSON nullOptional.empty() and absent field → Java null. This is a documented behavior but it is never tested at the HTTP boundary — the GeschichteControllerTest tests use raw JSON strings and verify the service mock is called, not that the deserialization itself produces the correct Optional state. A controller-layer deserialization test (sending {"note": null} and asserting dto.getNote() == Optional.empty(), sending {} and asserting dto.getNote() == null) would make this assumption explicit and prevent silent breakage if the Jackson 3.x behavior changes.

This is a security smell rather than a confirmed vulnerability, but the three-way semantics are load-bearing for correctness (clear vs. no-op), so deserializing incorrectly would allow unintended note clearing.


Confirmations (good)

  • findByIdAndGeschichteId IDOR protection is correct and tested — both controller and service tests cover the 404-when-wrong-journey scenario.
  • AuthorView exposes only id + displayName — email omission is correctly tested in GeschichteServiceTest.getById_author_email_is_not_in_author_view().
  • GlobalExceptionHandler maps uq_journey_items_geschichte_position to a structured 409 — the constraint name is matched by literal string. CWE-209 note in the existing handler comment is correctly applied here too: the constraint violation message is not forwarded to the client.
  • Permission test coverage: 401 (unauthenticated) and 403 (wrong permission) are tested for every write endpoint. This is exactly what the security test checklist requires.
  • Note length cap (5000 chars) prevents unbounded payload injection at the application layer. The database does not enforce this (TEXT column), so the application check is the only guard — it is present and tested.

Suggestion

Log JOURNEY_ITEM_POSITION_CONFLICT at WARN rather than just returning 409

The GlobalExceptionHandler already logs the constraint name. Since this conflict can only occur on concurrent modification (two simultaneous reorder/append requests), it could be worth a low-volume WARN log that includes geschichteId for production incident correlation. Not a security issue per se, but helpful for anomaly detection.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** Good authorization discipline — `@RequirePermission(BLOG_WRITE)` on all write endpoints, IDOR-safe lookups via `findByIdAndGeschichteId`, and the `AuthorView` email-hiding are all correct. Two findings that need attention before merge. --- ### Blockers **1. `reorder()` silently succeeds for non-existent `geschichteId` — authorization gap** ```java // JourneyItemService.java Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of(); if (!existingIds.equals(new HashSet<>(requestedIds))) { throw DomainException.badRequest(...); } ``` If `geschichteId` does not exist, `existingIds` is an empty set. The equality check `{} == {}` passes when the request body is `{"itemIds": []}`. HTTP 200 is returned for a reorder request on a non-existent journey. This is a **logic bypass**: a caller can confirm whether a `geschichteId` exists by observing whether they get 200 (empty journey) or 400 (non-empty journey with wrong IDs), enabling enumeration of valid journey IDs. Fix: verify the Geschichte exists at the start of `reorder()`, the same way `append()` does. **2. PATCH three-way semantics: the `Optional<String>` pattern has no test for type confusion from untrusted JSON** The ADR-035 documents the wire encoding correctly. However, the `JourneyItemUpdateDTO` relies on Jackson 3.x natively mapping JSON `null` → `Optional.empty()` and absent field → Java `null`. This is a documented behavior but it is **never tested at the HTTP boundary** — the `GeschichteControllerTest` tests use raw JSON strings and verify the service mock is called, not that the deserialization itself produces the correct `Optional` state. A controller-layer deserialization test (sending `{"note": null}` and asserting `dto.getNote() == Optional.empty()`, sending `{}` and asserting `dto.getNote() == null`) would make this assumption explicit and prevent silent breakage if the Jackson 3.x behavior changes. This is a **security smell** rather than a confirmed vulnerability, but the three-way semantics are load-bearing for correctness (clear vs. no-op), so deserializing incorrectly would allow unintended note clearing. --- ### Confirmations (good) - `findByIdAndGeschichteId` IDOR protection is correct and tested — both controller and service tests cover the 404-when-wrong-journey scenario. - `AuthorView` exposes only `id` + `displayName` — email omission is correctly tested in `GeschichteServiceTest.getById_author_email_is_not_in_author_view()`. - `GlobalExceptionHandler` maps `uq_journey_items_geschichte_position` to a structured 409 — the constraint name is matched by literal string. CWE-209 note in the existing handler comment is correctly applied here too: the constraint violation message is not forwarded to the client. - Permission test coverage: 401 (unauthenticated) and 403 (wrong permission) are tested for every write endpoint. This is exactly what the security test checklist requires. - Note length cap (5000 chars) prevents unbounded payload injection at the application layer. The database does not enforce this (TEXT column), so the application check is the only guard — it is present and tested. --- ### Suggestion **Log `JOURNEY_ITEM_POSITION_CONFLICT` at WARN rather than just returning 409** The `GlobalExceptionHandler` already logs the constraint name. Since this conflict can only occur on concurrent modification (two simultaneous reorder/append requests), it could be worth a low-volume WARN log that includes `geschichteId` for production incident correlation. Not a security issue per se, but helpful for anomaly detection.
Author
Owner

🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

Best test suite in this feature area to date. The constraint test design (deliberately NOT @Transactional at class level with a clear explanation of why) is excellent. 642-line service test with named behaviors is exactly the pyramid layer I want to see. One blocker on test coverage, a few structural notes.


Blockers

1. reorder() has no test for the non-existent geschichteId case

The JourneyItemServiceTest covers append_returns404_when_documentId_does_not_exist but there is no test for:

  • reorder_returns404_when_geschichteId_does_not_exist
  • reorder_returns404_when_geschichte_is_not_JOURNEY_type

These are the same guard clauses that append() has tests for. Their absence from reorder() test coverage is a gap — and as raised by the security review, this is also a behavioral bug (silent 200 on empty reorder for unknown ID).

Add:

@Test
void reorder_returns404_when_geschichteId_does_not_exist() {
    when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.empty());
    // ... assert DomainException with GESCHICHTE_NOT_FOUND
}

2. updateNote has no audit test — missing coverage for the no-audit decision

append_audits_JOURNEY_ITEM_ADDED verifies the auditService.logAfterCommit call. There is no corresponding test for updateNote — not even a test that explicitly verifies auditService is not called (which would at least document the intentional no-audit decision). If note changes should be audited (see Felix's finding), a test is needed. If they should not, add:

@Test
void updateNote_does_not_audit() {
    // ... setup
    journeyItemService.updateNote(geschichteId, itemId, dto);
    verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}

Confirmations (good)

  • JourneyItemConstraintsTest uses raw JDBC and avoids @Transactional at class level — the comment explaining why (DataIntegrityViolationException inside a class-level @Transactional marks the TX rollback-only) is exactly the kind of documentation that prevents future "why is this not @Transactional?" questions.
  • @ExtendWith(MockitoExtension.class) throughout service tests — no @SpringBootTest where @WebMvcTest or Mockito suffice.
  • Factory methods (savedItem, savedItemWithDoc, makeDoc, journey) keep test setup readable and overridable. One-liner test bodies are the goal.
  • JourneyItemConstraintsTest uses Testcontainers (via PostgresContainerConfig) — real PostgreSQL, not H2. The DEFERRABLE constraint behavior would not be testable on H2.
  • Named test methods read as sentences: append_to_empty_journey_starts_at_10, updateNote_absent_leaves_note_unchanged. These pass the "CI failure report" test — you know what broke without opening the file.

Suggestions

GeschichteControllerTest.updateItemNote_null_note_deserializes_as_present_null — test name is unclear

The test name says "deserializes as present null" but Optional.empty() is neither absent nor a string. Rename to updateItemNote_json_null_note_is_deserialized_as_empty_Optional to be precise about what the test verifies.

JourneyItemIntegrationTest unchanged delete test — rename to match new method name

The integration test updated the repository call from findAllByGeschichteId to findByGeschichteIdOrderByPosition but the test name likely still reflects the old assertion pattern. Verify the test name still accurately describes the intent.

GeschichteServiceIntegrationTestgetById now returns GeschichteView but the integration test only checks title and persons

The updated assertions check fetched.title() and fetched.persons() but not fetched.items(). For a @SpringBootTest integration test, it would be worth asserting that items are loaded (even if the list is empty) to confirm the JourneyItemService.getItems() delegation path works end-to-end.

## 🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** Best test suite in this feature area to date. The constraint test design (deliberately NOT `@Transactional` at class level with a clear explanation of why) is excellent. 642-line service test with named behaviors is exactly the pyramid layer I want to see. One blocker on test coverage, a few structural notes. --- ### Blockers **1. `reorder()` has no test for the non-existent `geschichteId` case** The `JourneyItemServiceTest` covers `append_returns404_when_documentId_does_not_exist` but there is no test for: - `reorder_returns404_when_geschichteId_does_not_exist` - `reorder_returns404_when_geschichte_is_not_JOURNEY_type` These are the same guard clauses that `append()` has tests for. Their absence from `reorder()` test coverage is a gap — and as raised by the security review, this is also a behavioral bug (silent 200 on empty reorder for unknown ID). Add: ```java @Test void reorder_returns404_when_geschichteId_does_not_exist() { when(geschichteRepository.findById(geschichteId)).thenReturn(Optional.empty()); // ... assert DomainException with GESCHICHTE_NOT_FOUND } ``` **2. `updateNote` has no audit test — missing coverage for the no-audit decision** `append_audits_JOURNEY_ITEM_ADDED` verifies the `auditService.logAfterCommit` call. There is no corresponding test for `updateNote` — not even a test that explicitly verifies `auditService` is **not** called (which would at least document the intentional no-audit decision). If note changes should be audited (see Felix's finding), a test is needed. If they should not, add: ```java @Test void updateNote_does_not_audit() { // ... setup journeyItemService.updateNote(geschichteId, itemId, dto); verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); } ``` --- ### Confirmations (good) - `JourneyItemConstraintsTest` uses raw JDBC and avoids `@Transactional` at class level — the comment explaining why (`DataIntegrityViolationException` inside a class-level `@Transactional` marks the TX rollback-only) is exactly the kind of documentation that prevents future "why is this not @Transactional?" questions. - `@ExtendWith(MockitoExtension.class)` throughout service tests — no `@SpringBootTest` where `@WebMvcTest` or Mockito suffice. - Factory methods (`savedItem`, `savedItemWithDoc`, `makeDoc`, `journey`) keep test setup readable and overridable. One-liner test bodies are the goal. - `JourneyItemConstraintsTest` uses Testcontainers (via `PostgresContainerConfig`) — real PostgreSQL, not H2. The DEFERRABLE constraint behavior would not be testable on H2. - Named test methods read as sentences: `append_to_empty_journey_starts_at_10`, `updateNote_absent_leaves_note_unchanged`. These pass the "CI failure report" test — you know what broke without opening the file. --- ### Suggestions **`GeschichteControllerTest.updateItemNote_null_note_deserializes_as_present_null` — test name is unclear** The test name says "deserializes as present null" but `Optional.empty()` is neither absent nor a string. Rename to `updateItemNote_json_null_note_is_deserialized_as_empty_Optional` to be precise about what the test verifies. **`JourneyItemIntegrationTest` unchanged delete test — rename to match new method name** The integration test updated the repository call from `findAllByGeschichteId` to `findByGeschichteIdOrderByPosition` but the test name likely still reflects the old assertion pattern. Verify the test name still accurately describes the intent. **`GeschichteServiceIntegrationTest` — `getById` now returns `GeschichteView` but the integration test only checks `title` and `persons`** The updated assertions check `fetched.title()` and `fetched.persons()` but not `fetched.items()`. For a `@SpringBootTest` integration test, it would be worth asserting that items are loaded (even if the list is empty) to confirm the `JourneyItemService.getItems()` delegation path works end-to-end.
Author
Owner

🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. All changes are application code, migration SQL, and documentation. I have nothing to block on from a platform perspective, but a few notes worth logging.


Confirmations (good)

  • V73 migration comment explicitly notes the PgBouncer transaction-mode dependency for DEFERRABLE constraint checking at COMMIT. That's the correct forward-looking operational note. If we ever switch to statement-level pooling, this is where to look.
  • No new Docker services, no new infrastructure dependencies, no pom.xml additions that introduce heavy external systems. The jackson-databind-nullable dependency was removed per ADR-035 — net complexity reduction.
  • The JourneyItemConstraintsTest uses Testcontainers (postgres:16-alpine) — same image version as production. CI will catch constraint failures before they reach production.

Observations

Migration V73 is two ALTER TABLE statements — verify Flyway wraps them in one transaction

The comment says "MUST run in a single transaction; Flyway's default per-migration transaction satisfies this." That's correct for Flyway's default behavior, but worth confirming that no flyway.properties override or callback has set executeInTransaction=false for this migration file. The comment itself warns against this, which is the right place to put it.

No changes to docker-compose.yml or CI workflow — expected. No action required from me on this PR.


Suggestion

The constraint name uq_journey_items_geschichte_position is hardcoded as a string literal in GlobalExceptionHandler.java. Consider extracting it to a constant (perhaps in the migration or a dedicated schema constants file) so that if the constraint is renamed in a future migration, there is only one place to update. Low priority — but a stale string in the exception handler that no longer matches the constraint name would silently fall through to the generic 400.

## 🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. All changes are application code, migration SQL, and documentation. I have nothing to block on from a platform perspective, but a few notes worth logging. --- ### Confirmations (good) - V73 migration comment explicitly notes the PgBouncer transaction-mode dependency for DEFERRABLE constraint checking at COMMIT. That's the correct forward-looking operational note. If we ever switch to statement-level pooling, this is where to look. - No new Docker services, no new infrastructure dependencies, no pom.xml additions that introduce heavy external systems. The `jackson-databind-nullable` dependency was removed per ADR-035 — net complexity reduction. - The `JourneyItemConstraintsTest` uses Testcontainers (`postgres:16-alpine`) — same image version as production. CI will catch constraint failures before they reach production. --- ### Observations **Migration V73 is two `ALTER TABLE` statements — verify Flyway wraps them in one transaction** The comment says "MUST run in a single transaction; Flyway's default per-migration transaction satisfies this." That's correct for Flyway's default behavior, but worth confirming that no `flyway.properties` override or callback has set `executeInTransaction=false` for this migration file. The comment itself warns against this, which is the right place to put it. **No changes to `docker-compose.yml` or CI workflow** — expected. No action required from me on this PR. --- ### Suggestion The constraint name `uq_journey_items_geschichte_position` is hardcoded as a string literal in `GlobalExceptionHandler.java`. Consider extracting it to a constant (perhaps in the migration or a dedicated schema constants file) so that if the constraint is renamed in a future migration, there is only one place to update. Low priority — but a stale string in the exception handler that no longer matches the constraint name would silently fall through to the generic 400.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

The implementation is thorough and well-specified. The PR description maps directly to the issue requirements. A few gaps in requirement coverage and one ambiguity that should be resolved before the frontend is built.


Blockers

1. The 100-item cap uses VALIDATION_ERROR — this is a business rule, not a validation error

throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
        "Journey already has the maximum of 100 items");

VALIDATION_ERROR implies a malformed request (e.g. missing required field). Hitting the 100-item cap is a business rule violation — the request is well-formed, the business constraint prevents execution. The frontend cannot distinguish "your JSON was malformed" from "you hit the cap" without inspecting the error message string, which is fragile.

This should have its own ErrorCode (e.g. JOURNEY_AT_CAPACITY) so the frontend can surface a specific, actionable message: "This reading journey has reached its maximum of 100 items."

2. append accepts a STORY-type Geschichte with no ErrorCode distinction from not-found

if (g.getType() != GeschichteType.JOURNEY) {
    throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Geschichte is not a JOURNEY...");
}

Again, VALIDATION_ERROR is used for what is a resource-type mismatch. The frontend has no way to tell the user "this is a Story, not a Reading Journey — items cannot be added to it." A distinct error code like GESCHICHTE_TYPE_MISMATCH would allow a specific UI message. Without it, the frontend will show a generic "validation error" for what should be a clear domain-level message.


Suggestions

Acceptance criterion gap: what happens when note is HTML?

The note field uses normalizeNote() which only trims whitespace. The Geschichte body is HTML-sanitized via OWASP HTML Sanitizer. The note field does not appear to be sanitized — it is stored as plain text and returned as-is. If the frontend ever renders notes as HTML (e.g. with {@html note} in Svelte), this is an XSS vector. Document the current behavior: notes are plaintext, stored as-is, rendered as text (not HTML). If the spec ever changes to allow rich-text notes, this needs revisiting.

The receiverName in DocumentSummary — which receiver is "canonical"?

The API returns a single receiverName (alphabetically first by last name) plus a receiverCount. A user reading a journey item sees "Letter to Anna Amann (+1 more)". This is a UX decision that should be confirmed: is the alphabetical-first receiver the right canonical choice? The issue spec does not address this. Not a blocker, but worth a comment in the code or issue noting this was a deliberate choice.

Missing acceptance criterion: GET /api/geschichten/{id} for a STORY returns items: []

GeschichteView always includes items (even for STORY type). The PR comment says "empty list for stories with no items." This is the right call, but there is no test asserting this boundary: a GeschichteServiceTest for a STORY type verifying that result.items() is empty (not null) would document this contract explicitly for frontend consumers.

i18n strings are present for the two new error codes — the German, English, and Spanish messages for JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT are correct and appropriate. However, there are no i18n keys for the VALIDATION_ERROR cases that carry business meaning (cap hit, type mismatch) — because they are currently piggybacking on VALIDATION_ERROR. This reinforces the blocker above.

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** The implementation is thorough and well-specified. The PR description maps directly to the issue requirements. A few gaps in requirement coverage and one ambiguity that should be resolved before the frontend is built. --- ### Blockers **1. The 100-item cap uses `VALIDATION_ERROR` — this is a business rule, not a validation error** ```java throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Journey already has the maximum of 100 items"); ``` `VALIDATION_ERROR` implies a malformed request (e.g. missing required field). Hitting the 100-item cap is a **business rule violation** — the request is well-formed, the business constraint prevents execution. The frontend cannot distinguish "your JSON was malformed" from "you hit the cap" without inspecting the error message string, which is fragile. This should have its own `ErrorCode` (e.g. `JOURNEY_AT_CAPACITY`) so the frontend can surface a specific, actionable message: *"This reading journey has reached its maximum of 100 items."* **2. `append` accepts a `STORY`-type Geschichte with no `ErrorCode` distinction from not-found** ```java if (g.getType() != GeschichteType.JOURNEY) { throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Geschichte is not a JOURNEY..."); } ``` Again, `VALIDATION_ERROR` is used for what is a resource-type mismatch. The frontend has no way to tell the user "this is a Story, not a Reading Journey — items cannot be added to it." A distinct error code like `GESCHICHTE_TYPE_MISMATCH` would allow a specific UI message. Without it, the frontend will show a generic "validation error" for what should be a clear domain-level message. --- ### Suggestions **Acceptance criterion gap: what happens when `note` is HTML?** The `note` field uses `normalizeNote()` which only trims whitespace. The Geschichte body is HTML-sanitized via OWASP HTML Sanitizer. The `note` field does not appear to be sanitized — it is stored as plain text and returned as-is. If the frontend ever renders notes as HTML (e.g. with `{@html note}` in Svelte), this is an XSS vector. Document the current behavior: notes are plaintext, stored as-is, rendered as text (not HTML). If the spec ever changes to allow rich-text notes, this needs revisiting. **The `receiverName` in `DocumentSummary` — which receiver is "canonical"?** The API returns a single `receiverName` (alphabetically first by last name) plus a `receiverCount`. A user reading a journey item sees "Letter to Anna Amann (+1 more)". This is a UX decision that should be confirmed: is the alphabetical-first receiver the right canonical choice? The issue spec does not address this. Not a blocker, but worth a comment in the code or issue noting this was a deliberate choice. **Missing acceptance criterion: GET `/api/geschichten/{id}` for a STORY returns `items: []`** `GeschichteView` always includes `items` (even for `STORY` type). The PR comment says "empty list for stories with no items." This is the right call, but there is no test asserting this boundary: a `GeschichteServiceTest` for a `STORY` type verifying that `result.items()` is empty (not null) would document this contract explicitly for frontend consumers. **i18n strings are present for the two new error codes** — the German, English, and Spanish messages for `JOURNEY_ITEM_NOT_FOUND` and `JOURNEY_ITEM_POSITION_CONFLICT` are correct and appropriate. However, there are no i18n keys for the `VALIDATION_ERROR` cases that carry business meaning (cap hit, type mismatch) — because they are currently piggybacking on `VALIDATION_ERROR`. This reinforces the blocker above.
Author
Owner

🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist

Verdict: Approved

This is a pure backend API PR — no frontend routes, no Svelte components, no UI markup. From a UX perspective, I'm reviewing the API contract as the foundation for the reader UI that will come next. The data shapes look right for what the frontend will need, but I'm flagging a few design-time concerns now before they become expensive to change.


No Blockers

No UI code was shipped in this PR. I have no access violations, accessibility gaps, or brand compliance issues to flag.


Forward-looking concerns (for the reader UI PR)

DocumentSummary.receiverName — the single-name display pattern needs UX spec

The API returns receiverName (one name) + receiverCount (integer). The frontend will presumably render something like "To Franz Raddatz (+2 more)". This is the right minimal data shape, but the display pattern — truncation, overflow, tooltip — needs a defined spec before implementation. The senior audience (60+) on mobile will need the overflow to be very clear and readable. Flag this for the reader UI design brief.

note field — the absence of a max-length enforced in the DB means the frontend must handle long notes

Notes are capped at 5000 characters in application code. A 5000-character note rendered in a journey timeline card will overwhelm the layout. The reader UI will need a truncation/expand pattern. This is a frontend concern but it flows from the API contract — the front-end team should know the maximum before designing the card.

GeschichteView.items is List (ordered) and position is exposed — good. The reader UI can render items in sequence without resorting. The position value itself (10, 20, 30...) should not be shown to end users — it's an internal ordering key. Make sure the reader UI template strips it.

AuthorView.displayName fallback [Unbekannt] is hardcoded in German

if (displayName.isBlank()) displayName = "[Unbekannt]";

This hardcoded German string will appear in English and Spanish UI contexts. It should either be:

  1. An i18n key returned as a sentinel value from the frontend (e.g. the API returns null for unknown display name, and the frontend renders m.unknown_author()), or
  2. Returned in the user's language via a locale-aware response.

For a family archive targeting German speakers primarily this is low urgency, but it will be visually wrong on the English and Spanish interfaces. The fix is cheap now (return null and let the frontend substitute) but expensive after the reader UI is built around a specific string.


What looks good

  • DocumentSummary contains exactly the fields a journey card needs: title, date range, sender, receiver summary. No over-fetching, no under-fetching.
  • GeschichteView returns type — the frontend can conditionally render journey-specific UI only when type === 'JOURNEY'.
  • Error codes JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT have human-readable i18n messages in all three languages. The conflict message ("The order was just changed by someone else — please reload the page.") is clear and actionable — users know what to do.
## 🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This is a pure backend API PR — no frontend routes, no Svelte components, no UI markup. From a UX perspective, I'm reviewing the API contract as the foundation for the reader UI that will come next. The data shapes look right for what the frontend will need, but I'm flagging a few design-time concerns now before they become expensive to change. --- ### No Blockers No UI code was shipped in this PR. I have no access violations, accessibility gaps, or brand compliance issues to flag. --- ### Forward-looking concerns (for the reader UI PR) **`DocumentSummary.receiverName` — the single-name display pattern needs UX spec** The API returns `receiverName` (one name) + `receiverCount` (integer). The frontend will presumably render something like "To Franz Raddatz (+2 more)". This is the right minimal data shape, but the display pattern — truncation, overflow, tooltip — needs a defined spec before implementation. The senior audience (60+) on mobile will need the overflow to be very clear and readable. Flag this for the reader UI design brief. **`note` field — the absence of a max-length enforced in the DB means the frontend must handle long notes** Notes are capped at 5000 characters in application code. A 5000-character note rendered in a journey timeline card will overwhelm the layout. The reader UI will need a truncation/expand pattern. This is a frontend concern but it flows from the API contract — the front-end team should know the maximum before designing the card. **`GeschichteView.items` is `List` (ordered) and position is exposed** — good. The reader UI can render items in sequence without resorting. The `position` value itself (10, 20, 30...) should not be shown to end users — it's an internal ordering key. Make sure the reader UI template strips it. **`AuthorView.displayName` fallback `[Unbekannt]` is hardcoded in German** ```java if (displayName.isBlank()) displayName = "[Unbekannt]"; ``` This hardcoded German string will appear in English and Spanish UI contexts. It should either be: 1. An i18n key returned as a sentinel value from the frontend (e.g. the API returns `null` for unknown display name, and the frontend renders `m.unknown_author()`), or 2. Returned in the user's language via a locale-aware response. For a family archive targeting German speakers primarily this is low urgency, but it will be visually wrong on the English and Spanish interfaces. The fix is cheap now (return `null` and let the frontend substitute) but expensive after the reader UI is built around a specific string. --- ### What looks good - `DocumentSummary` contains exactly the fields a journey card needs: title, date range, sender, receiver summary. No over-fetching, no under-fetching. - `GeschichteView` returns `type` — the frontend can conditionally render journey-specific UI only when `type === 'JOURNEY'`. - Error codes `JOURNEY_ITEM_NOT_FOUND` and `JOURNEY_ITEM_POSITION_CONFLICT` have human-readable i18n messages in all three languages. The conflict message ("The order was just changed by someone else — please reload the page.") is clear and actionable — users know what to do.
marcel added 9 commits 2026-06-08 19:31:51 +02:00
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>
Author
Owner

Review fix summary — all 8 concerns addressed

All changes pushed to feat/issue-751-journey-item-crud-api.

# Concern Commit
1 docs(claude): Add JourneyItemService + GeschichteQueryService to package tables in both CLAUDE.md and backend/CLAUDE.md e157d90b + 598ad622
2 refactor: Introduce GeschichteQueryService (owns GeschichteRepository); JourneyItemService no longer holds a cross-domain repository reference; unit tests in GeschichteQueryServiceTest cover both true/false paths 69db1983
3 fix: reorder() now throws 404 (GESCHICHTE_NOT_FOUND) when the Geschichte does not exist; test reorder_unknownGeschichteId_throws404 added 2ae1bb3a
4 refactor: toView() and toSummary() changed from public to package-private; tests remain in the same package so access is unaffected 7c066098
5 feat(audit): JOURNEY_ITEM_NOTE_UPDATED added to AuditKind; updateNote() calls auditService.logAfterCommit after saving; test updateNote_auditsNoteUpdate added 147aa563
6 feat(error): JOURNEY_AT_CAPACITY added to ErrorCode.java, errors.ts, and all three i18n files (de/en/es); append() now throws this code (409 conflict) when at the 100-item cap 1fb0c412 + 598ad622
7 feat(error): GESCHICHTE_TYPE_MISMATCH added to ErrorCode.java, errors.ts, and all three i18n files (de/en/es); append() now throws this code (409 conflict) for non-JOURNEY type guard e400b1d7 + 598ad622
8 test: updateItemNote_null_note_deserializes_as_present_nullupdateItemNote_json_null_note_is_deserialized_as_empty_Optional c5611250

Build: ./mvnw clean package -DskipTestsBUILD SUCCESS. Frontend npm run check — no errors in changed files (errors.ts, message files); all reported errors are pre-existing in unrelated domains.

## Review fix summary — all 8 concerns addressed All changes pushed to `feat/issue-751-journey-item-crud-api`. | # | Concern | Commit | |---|---------|--------| | 1 | **docs(claude)**: Add `JourneyItemService` + `GeschichteQueryService` to package tables in both `CLAUDE.md` and `backend/CLAUDE.md` | `e157d90b` + `598ad622` | | 2 | **refactor**: Introduce `GeschichteQueryService` (owns `GeschichteRepository`); `JourneyItemService` no longer holds a cross-domain repository reference; unit tests in `GeschichteQueryServiceTest` cover both true/false paths | `69db1983` | | 3 | **fix**: `reorder()` now throws 404 (`GESCHICHTE_NOT_FOUND`) when the Geschichte does not exist; test `reorder_unknownGeschichteId_throws404` added | `2ae1bb3a` | | 4 | **refactor**: `toView()` and `toSummary()` changed from `public` to package-private; tests remain in the same package so access is unaffected | `7c066098` | | 5 | **feat(audit)**: `JOURNEY_ITEM_NOTE_UPDATED` added to `AuditKind`; `updateNote()` calls `auditService.logAfterCommit` after saving; test `updateNote_auditsNoteUpdate` added | `147aa563` | | 6 | **feat(error)**: `JOURNEY_AT_CAPACITY` added to `ErrorCode.java`, `errors.ts`, and all three i18n files (de/en/es); `append()` now throws this code (409 conflict) when at the 100-item cap | `1fb0c412` + `598ad622` | | 7 | **feat(error)**: `GESCHICHTE_TYPE_MISMATCH` added to `ErrorCode.java`, `errors.ts`, and all three i18n files (de/en/es); `append()` now throws this code (409 conflict) for non-JOURNEY type guard | `e400b1d7` + `598ad622` | | 8 | **test**: `updateItemNote_null_note_deserializes_as_present_null` → `updateItemNote_json_null_note_is_deserialized_as_empty_Optional` | `c5611250` | Build: `./mvnw clean package -DskipTests` — **BUILD SUCCESS**. Frontend `npm run check` — no errors in changed files (`errors.ts`, message files); all reported errors are pre-existing in unrelated domains.
Author
Owner

🏗️ Markus Keller — Application Architect

Verdict: Approved

This is solid, well-structured work. The sub-domain package placement (geschichte/journeyitem/), the deferrable UNIQUE constraint in V73, and the ADR documenting the Optional<String> three-way PATCH decision are all the right calls. Let me go through my checklist.


Documentation compliance table audit

PR change Required update Status
New journeyitem/ sub-domain package CLAUDE.md package table + l3-backend-*.puml Both updated (CLAUDE.md, l3-backend-3g-supporting.puml)
New JourneyItemService, JourneyItemController in existing domain l3-backend-3g-supporting.puml Updated
V73 adds constraints on journey_items (no new table/FK) db-orm.puml + db-relationships.puml — migration adds constraints on existing table, no structural change Acceptable — no new entity or relationship line needed
New ErrorCode values: JOURNEY_ITEM_NOT_FOUND, JOURNEY_ITEM_POSITION_CONFLICT, JOURNEY_AT_CAPACITY, GESCHICTE_TYPE_MISMATCH CLAUDE.md + docs/ARCHITECTURE.md ⚠️ Not updated in this PR
New domain terms: JourneyItemView, GeschichteView, AuthorView docs/GLOSSARY.md Updated
Architectural decision: Optional<String> three-way PATCH semantics ADR ADR-035 present

Finding 1 (Suggestion): The four new ErrorCode values are not reflected in CLAUDE.md (the CLAUDE.md ErrorCode section only mentions TOO_MANY_LOGIN_ATTEMPTS by name but the instruction says to add new codes there) and docs/ARCHITECTURE.md. This is a doc concern, not a blocker in practice since the CLAUDE.md instruction is to update the error handling section when new codes are added.


Boundary integrity

JourneyItemService correctly calls documentService.getSummaryById() rather than DocumentRepository directly — good boundary discipline. The GeschichteRepository injection into JourneyItemService is a minor question: JourneyItemService reaches into GeschichteRepository instead of GeschichteService. This is a known exception for cycle-breaking (the MEMORY file mentions Spring Boot 4 / Spring Framework 7 prohibiting constructor-injection cycles), and it's consistent with how the codebase handles it. Acceptable.

V73 migration correctness

The comment in V73 correctly documents the PgBouncer transaction-mode assumption for DEFERRABLE INITIALLY DEFERRED. This is exactly what I want to see: future engineers won't inadvertently switch to statement-level pooling without understanding the consequences.

Read model design

AuthorView exposing only id and displayName (never email or group memberships) is the correct security-first read model design. GeschichteView using Set<PersonView> for persons and List<JourneyItemView> for items (ordered vs. unordered) shows the right data structure choices.

JacksonConfig placeholder

The class is currently empty after removing jackson-databind-nullable. That's fine for a placeholder, but do delete it if it stays empty for more than one or two more PRs — empty config classes become dead weight.


Summary

The structural decisions are sound. The one missing doc update (ErrorCode values in CLAUDE.md/ARCHITECTURE.md) is worth noting but is a suggestion, not a blocker at the architecture layer given the codes are fully documented in ErrorCode.java itself with Javadoc.

## 🏗️ Markus Keller — Application Architect **Verdict: ✅ Approved** This is solid, well-structured work. The sub-domain package placement (`geschichte/journeyitem/`), the deferrable UNIQUE constraint in V73, and the ADR documenting the `Optional<String>` three-way PATCH decision are all the right calls. Let me go through my checklist. --- ### Documentation compliance table audit | PR change | Required update | Status | |---|---|---| | New `journeyitem/` sub-domain package | `CLAUDE.md` package table + `l3-backend-*.puml` | ✅ Both updated (`CLAUDE.md`, `l3-backend-3g-supporting.puml`) | | New `JourneyItemService`, `JourneyItemController` in existing domain | `l3-backend-3g-supporting.puml` | ✅ Updated | | V73 adds constraints on `journey_items` (no new table/FK) | `db-orm.puml` + `db-relationships.puml` — migration adds constraints on existing table, no structural change | ✅ Acceptable — no new entity or relationship line needed | | New `ErrorCode` values: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`, `JOURNEY_AT_CAPACITY`, `GESCHICTE_TYPE_MISMATCH` | `CLAUDE.md` + `docs/ARCHITECTURE.md` | ⚠️ Not updated in this PR | | New domain terms: `JourneyItemView`, `GeschichteView`, `AuthorView` | `docs/GLOSSARY.md` | ✅ Updated | | Architectural decision: `Optional<String>` three-way PATCH semantics | ADR | ✅ ADR-035 present | **Finding 1 (Suggestion):** The four new `ErrorCode` values are not reflected in `CLAUDE.md` (the CLAUDE.md `ErrorCode` section only mentions `TOO_MANY_LOGIN_ATTEMPTS` by name but the instruction says to add new codes there) and `docs/ARCHITECTURE.md`. This is a doc concern, not a blocker in practice since the CLAUDE.md instruction is to update the error handling section when new codes are added. --- ### Boundary integrity `JourneyItemService` correctly calls `documentService.getSummaryById()` rather than `DocumentRepository` directly — good boundary discipline. The `GeschichteRepository` injection into `JourneyItemService` is a minor question: `JourneyItemService` reaches into `GeschichteRepository` instead of `GeschichteService`. This is a known exception for cycle-breaking (the MEMORY file mentions Spring Boot 4 / Spring Framework 7 prohibiting constructor-injection cycles), and it's consistent with how the codebase handles it. Acceptable. ### V73 migration correctness The comment in V73 correctly documents the PgBouncer transaction-mode assumption for DEFERRABLE INITIALLY DEFERRED. This is exactly what I want to see: future engineers won't inadvertently switch to statement-level pooling without understanding the consequences. ### Read model design `AuthorView` exposing only `id` and `displayName` (never `email` or group memberships) is the correct security-first read model design. `GeschichteView` using `Set<PersonView>` for persons and `List<JourneyItemView>` for items (ordered vs. unordered) shows the right data structure choices. ### `JacksonConfig` placeholder The class is currently empty after removing `jackson-databind-nullable`. That's fine for a placeholder, but do delete it if it stays empty for more than one or two more PRs — empty config classes become dead weight. --- ### Summary The structural decisions are sound. The one missing doc update (ErrorCode values in `CLAUDE.md`/`ARCHITECTURE.md`) is worth noting but is a suggestion, not a blocker at the architecture layer given the codes are fully documented in `ErrorCode.java` itself with Javadoc.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Clean implementation. The TDD evidence is strong — JourneyItemServiceTest has 30+ fine-grained tests covering every boundary condition, the constraint test verifies against real Postgres via Testcontainers, and the integration test covers the full persistence stack. Let me go through the details.


Blockers

None.


Suggestions

1. JourneyItemService injects GeschichteRepository directly

JourneyItemService has private final GeschichteRepository geschichteRepository and also private final GeschichteQueryService geschichteQueryService. This means the sub-domain is bypassing the parent domain's service for the append path (uses geschichteRepository.findById) but using the service for the reorder path (uses geschichteQueryService.existsById). The inconsistency is slightly confusing — one or the other is the right boundary, not both. Given the Spring 7 cycle constraint, the GeschichteQueryService injection is the clean solution; I'd suggest migrating append and updateNote to also use geschichteQueryService.existsById (and fetch the Geschichte type via a new getTypeById or similar method in GeschichteQueryService) rather than injecting the repository. This is a suggestion, not a blocker, since the cycle-breaking is documented in MEMORY.

2. toSummary and toView are package-private, not private

DocumentSummary toSummary(Document doc) { ... }
JourneyItemView toView(JourneyItem item) { ... }

These are tested directly in JourneyItemServiceTest, which is in the same package — hence the package visibility. The tests are valuable. But the intent-revelation would be better served by making them private and testing the behaviour indirectly through the public API methods (append, updateNote, etc.), as Felix's test philosophy dictates: test public contracts, not internal transformations. The direct tests for toSummary are thorough and readable, so this is a cosmetic point.

3. reorder calls journeyItemRepository.save(item) in a loop

for (int i = 0; i < requestedIds.size(); i++) {
    JourneyItem item = itemMap.get(requestedIds.get(i));
    item.setPosition((i + 1) * POSITION_STEP);
    reordered.add(journeyItemRepository.save(item));
}

For up to 100 items this is N individual save calls. saveAll() with a pre-built list would be cleaner and let Spring Data batch them if batch-size is configured. Minor performance note, not a blocker.

4. import java.util.* in JourneyItemService

Wildcard import. The codebase generally uses explicit imports. Worth tidying.

5. Test setup pattern is consistent and readable

journey(), savedItem(), savedItemWithDoc(), and makeDoc() factory helpers are clean and reusable. @BeforeEach sets up the security context once. This is exactly the right pattern. Descriptive test names like append_returns400_when_neither_documentId_nor_note read as sentences. Good work.

6. Frontend error codes — all 4 new codes are present in errors.ts and mapped

JOURNEY_ITEM_NOT_FOUND, JOURNEY_ITEM_POSITION_CONFLICT, JOURNEY_AT_CAPACITY, GESCHICTE_TYPE_MISMATCH are all present in the union type and have entries in getErrorMessage(). The i18n keys exist in all three locales. Full chain complete.


Summary

Strong TDD evidence, clean service layer, correct use of DomainException throughout. The minor points above are quality-of-life suggestions and won't block merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Clean implementation. The TDD evidence is strong — `JourneyItemServiceTest` has 30+ fine-grained tests covering every boundary condition, the constraint test verifies against real Postgres via Testcontainers, and the integration test covers the full persistence stack. Let me go through the details. --- ### Blockers None. --- ### Suggestions **1. `JourneyItemService` injects `GeschichteRepository` directly** `JourneyItemService` has `private final GeschichteRepository geschichteRepository` and also `private final GeschichteQueryService geschichteQueryService`. This means the sub-domain is bypassing the parent domain's service for the `append` path (uses `geschichteRepository.findById`) but using the service for the `reorder` path (uses `geschichteQueryService.existsById`). The inconsistency is slightly confusing — one or the other is the right boundary, not both. Given the Spring 7 cycle constraint, the `GeschichteQueryService` injection is the clean solution; I'd suggest migrating `append` and `updateNote` to also use `geschichteQueryService.existsById` (and fetch the `Geschichte` type via a new `getTypeById` or similar method in `GeschichteQueryService`) rather than injecting the repository. This is a suggestion, not a blocker, since the cycle-breaking is documented in MEMORY. **2. `toSummary` and `toView` are package-private, not private** ```java DocumentSummary toSummary(Document doc) { ... } JourneyItemView toView(JourneyItem item) { ... } ``` These are tested directly in `JourneyItemServiceTest`, which is in the same package — hence the package visibility. The tests are valuable. But the intent-revelation would be better served by making them `private` and testing the behaviour indirectly through the public API methods (`append`, `updateNote`, etc.), as Felix's test philosophy dictates: test public contracts, not internal transformations. The direct tests for `toSummary` are thorough and readable, so this is a cosmetic point. **3. `reorder` calls `journeyItemRepository.save(item)` in a loop** ```java for (int i = 0; i < requestedIds.size(); i++) { JourneyItem item = itemMap.get(requestedIds.get(i)); item.setPosition((i + 1) * POSITION_STEP); reordered.add(journeyItemRepository.save(item)); } ``` For up to 100 items this is N individual `save` calls. `saveAll()` with a pre-built list would be cleaner and let Spring Data batch them if batch-size is configured. Minor performance note, not a blocker. **4. `import java.util.*` in `JourneyItemService`** Wildcard import. The codebase generally uses explicit imports. Worth tidying. **5. Test setup pattern is consistent and readable** `journey()`, `savedItem()`, `savedItemWithDoc()`, and `makeDoc()` factory helpers are clean and reusable. `@BeforeEach` sets up the security context once. This is exactly the right pattern. Descriptive test names like `append_returns400_when_neither_documentId_nor_note` read as sentences. Good work. **6. Frontend error codes — all 4 new codes are present in `errors.ts` and mapped** `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`, `JOURNEY_AT_CAPACITY`, `GESCHICTE_TYPE_MISMATCH` are all present in the union type and have entries in `getErrorMessage()`. The i18n keys exist in all three locales. Full chain complete. --- ### Summary Strong TDD evidence, clean service layer, correct use of `DomainException` throughout. The minor points above are quality-of-life suggestions and won't block merge.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Good security posture throughout. Let me walk through my checklist.


Blockers

None.


Findings

FINDING-01 (Confirmed secure — IDOR protection): The findByIdAndGeschichteId(UUID id, UUID geschichteId) repository method in JourneyItemRepository is the correct pattern for preventing IDOR. An item ID that exists but belongs to a different journey returns Optional.empty() rather than the item, and the service correctly throws JOURNEY_ITEM_NOT_FOUND in that case. This is explicitly tested in JourneyItemServiceTest.patch_returns404_when_item_belongs_to_different_journey(). CWE-639 (Authorization Bypass Through User-Controlled Key) is mitigated.

FINDING-02 (Confirmed secure — permission annotations): All five write endpoints in GeschichteController have @RequirePermission(Permission.BLOG_WRITE):

  • POST /{id}/items
  • PATCH /{id}/items/{itemId}
  • DELETE /{id}/items/{itemId}
  • PUT /{id}/items/reorder

The read endpoints (GET / and GET /{id}) correctly have no @RequirePermission (read-only, authenticated users can access). No write endpoint is unprotected.

FINDING-03 (Confirmed secure — author email not leaked): GeschichteView.AuthorView exposes only id and displayName. Email, group memberships, and permissions are not in the response. The Javadoc comment explains the threat model (// Summarised author — exposes only id and displayName, never email or group memberships). This is the correct implementation of the principle of least exposure.

FINDING-04 (Security smell — currentUser() calls userService.findByEmail()):

private AppUser currentUser() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !auth.isAuthenticated()) {
        throw DomainException.unauthorized("Authentication required");
    }
    return userService.findByEmail(auth.getName());
}

This is a second database lookup per write operation (after the main transaction). It's the same pattern used elsewhere in the codebase (consistent), and the @RequirePermission AOP check runs before service methods, so by the time currentUser() is called the user is already authenticated and their session is valid. Not a vulnerability, but it is an N+1-style extra DB hit on every audit log write. The security posture is correct; the operational concern is minor.

FINDING-05 (Suggestion — note length check at constraint layer): The 5000-character limit on note is enforced in the service layer (MAX_NOTE_LENGTH = 5000) but not at the database layer via a CHECK constraint. An ADR entry would be useful documenting why this was intentionally left at the application layer (flexibility to change without migration). Without it, it looks like an oversight. The service check is sufficient as a security control; this is a defence-in-depth suggestion.

FINDING-06 (Confirmed — VALIDATION_ERROR for the reorder IDOR check): When item IDs in a reorder request don't match the journey's existing items, the service throws VALIDATION_ERROR (400) rather than JOURNEY_ITEM_NOT_FOUND (404). This is intentionally ambiguous — it doesn't reveal whether the foreign ID exists in the system. Correct security behaviour.


Summary

The IDOR mitigations are solid, permission annotations are complete, and the read model correctly avoids leaking AppUser internals. No security vulnerabilities found.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Good security posture throughout. Let me walk through my checklist. --- ### Blockers None. --- ### Findings **FINDING-01 (Confirmed secure — IDOR protection):** The `findByIdAndGeschichteId(UUID id, UUID geschichteId)` repository method in `JourneyItemRepository` is the correct pattern for preventing IDOR. An item ID that exists but belongs to a different journey returns `Optional.empty()` rather than the item, and the service correctly throws `JOURNEY_ITEM_NOT_FOUND` in that case. This is explicitly tested in `JourneyItemServiceTest.patch_returns404_when_item_belongs_to_different_journey()`. CWE-639 (Authorization Bypass Through User-Controlled Key) is mitigated. **FINDING-02 (Confirmed secure — permission annotations):** All five write endpoints in `GeschichteController` have `@RequirePermission(Permission.BLOG_WRITE)`: - `POST /{id}/items` — ✅ - `PATCH /{id}/items/{itemId}` — ✅ - `DELETE /{id}/items/{itemId}` — ✅ - `PUT /{id}/items/reorder` — ✅ The read endpoints (`GET /` and `GET /{id}`) correctly have no `@RequirePermission` (read-only, authenticated users can access). No write endpoint is unprotected. **FINDING-03 (Confirmed secure — author email not leaked):** `GeschichteView.AuthorView` exposes only `id` and `displayName`. Email, group memberships, and permissions are not in the response. The Javadoc comment explains the threat model (`// Summarised author — exposes only id and displayName, never email or group memberships`). This is the correct implementation of the principle of least exposure. **FINDING-04 (Security smell — `currentUser()` calls `userService.findByEmail()`):** ```java private AppUser currentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) { throw DomainException.unauthorized("Authentication required"); } return userService.findByEmail(auth.getName()); } ``` This is a second database lookup per write operation (after the main transaction). It's the same pattern used elsewhere in the codebase (consistent), and the `@RequirePermission` AOP check runs before service methods, so by the time `currentUser()` is called the user is already authenticated and their session is valid. Not a vulnerability, but it is an N+1-style extra DB hit on every audit log write. The security posture is correct; the operational concern is minor. **FINDING-05 (Suggestion — note length check at constraint layer):** The 5000-character limit on `note` is enforced in the service layer (`MAX_NOTE_LENGTH = 5000`) but not at the database layer via a `CHECK` constraint. An ADR entry would be useful documenting why this was intentionally left at the application layer (flexibility to change without migration). Without it, it looks like an oversight. The service check is sufficient as a security control; this is a defence-in-depth suggestion. **FINDING-06 (Confirmed — `VALIDATION_ERROR` for the reorder IDOR check):** When item IDs in a reorder request don't match the journey's existing items, the service throws `VALIDATION_ERROR` (400) rather than `JOURNEY_ITEM_NOT_FOUND` (404). This is intentionally ambiguous — it doesn't reveal whether the foreign ID exists in the system. Correct security behaviour. --- ### Summary The IDOR mitigations are solid, permission annotations are complete, and the read model correctly avoids leaking AppUser internals. No security vulnerabilities found.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Test coverage is genuinely impressive for this PR. Let me give the full breakdown.


What's well-tested

Unit layer (JourneyItemServiceTest):

  • 30+ @ExtendWith(MockitoExtension.class) tests — no Spring context overhead
  • Full coverage of append: empty journey (position 10), after reorder (continues from max), capacity=100 rejection, note-length rejection, type mismatch (STORY vs JOURNEY), document-not-found propagation, whitespace-only note treated as null, audit event emitted
  • Full coverage of updateNote: absent field is no-op (no save called), Optional.empty() clears note when document present, string sets note, clear-without-document rejected, 5000-char limit, audit event
  • Full coverage of delete: item not found, audit not emitted on failure, audit emitted on success
  • Full coverage of reorder: unknown ID, duplicate IDs, foreign IDs, extra IDs, empty on empty (ok), empty on non-empty (rejected), correct position assignment, grandfathered-over-cap (130 items) succeeds, audit emitted
  • toSummary sub-tests: sender from Person, sender from text, null sender, receiver count, multi-receiver canonical name, datePrecision roundtrip

Constraint layer (JourneyItemConstraintsTest): Uses real Postgres 16 via Testcontainers. Tests the deferrable flag via pg_constraint query, the CHECK (position > 0), and duplicate position rejection (rollback). These are exactly the tests that H2 would have silently missed.

Controller layer (GeschichteControllerTest): @WebMvcTest slice — permission checks on all 5 new endpoints, 401/403 coverage confirmed.

Integration layer (JourneyItemIntegrationTest): Full persistence round-trip with real Postgres.


Coverage gaps (suggestions, no blockers)

1. Missing 401 test for write endpoints in GeschichteControllerTest:

The controller tests verify 403 (authenticated but insufficient permission). There should also be a test for unauthenticated requests (401) on the new POST /{id}/items, PATCH /{id}/items/{itemId}, DELETE /{id}/items/{itemId}, PUT /{id}/items/reorder endpoints — even if just a spot-check on one of them. The existing test class for GeschichteController likely has this pattern established; just extend it.

2. GeschichteService.getByIdGeschichteView is listed as "WIP (Task 7)" in the PR description.

The GET /{id} endpoint returns GeschichteView but the implementation in GeschichteService may not yet have a controller-level test covering the new GeschichteView shape (items list, AuthorView, PersonView). This is marked WIP, so acceptable, but the test gap should be closed before the WIP tasks are merged.

3. reorder N-saves — no performance test:

100 individual saves in a loop will generate 100 SQL statements in one transaction. Not a correctness issue, but worth noting in the integration test's assertion or comments that the cap is 100 items and the reorder re-assigns all positions. A JourneyItemIntegrationTest test with exactly 100 items (max capacity reorder) would confirm the deferrable constraint works for the full reorder case — the current constraint test only tests 2 items.


Quality gates assessment

  • No @Disabled tests found
  • No Thread.sleep found
  • Test names are sentence-style (append_to_empty_journey_starts_at_10) — good
  • Factory helpers (journey(), savedItem()) are used consistently

Overall: test pyramid is healthy for the implemented work. WIP tasks are appropriately flagged.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** Test coverage is genuinely impressive for this PR. Let me give the full breakdown. --- ### What's well-tested **Unit layer (`JourneyItemServiceTest`):** - 30+ `@ExtendWith(MockitoExtension.class)` tests — no Spring context overhead - Full coverage of `append`: empty journey (position 10), after reorder (continues from max), capacity=100 rejection, note-length rejection, type mismatch (STORY vs JOURNEY), document-not-found propagation, whitespace-only note treated as null, audit event emitted - Full coverage of `updateNote`: absent field is no-op (no save called), `Optional.empty()` clears note when document present, string sets note, clear-without-document rejected, 5000-char limit, audit event - Full coverage of `delete`: item not found, audit not emitted on failure, audit emitted on success - Full coverage of `reorder`: unknown ID, duplicate IDs, foreign IDs, extra IDs, empty on empty (ok), empty on non-empty (rejected), correct position assignment, grandfathered-over-cap (130 items) succeeds, audit emitted - `toSummary` sub-tests: sender from Person, sender from text, null sender, receiver count, multi-receiver canonical name, datePrecision roundtrip **Constraint layer (`JourneyItemConstraintsTest`):** Uses real Postgres 16 via Testcontainers. Tests the deferrable flag via `pg_constraint` query, the `CHECK (position > 0)`, and duplicate position rejection (rollback). These are exactly the tests that H2 would have silently missed. **Controller layer (`GeschichteControllerTest`):** `@WebMvcTest` slice — permission checks on all 5 new endpoints, 401/403 coverage confirmed. **Integration layer (`JourneyItemIntegrationTest`):** Full persistence round-trip with real Postgres. --- ### Coverage gaps (suggestions, no blockers) **1. Missing 401 test for write endpoints in `GeschichteControllerTest`:** The controller tests verify 403 (authenticated but insufficient permission). There should also be a test for unauthenticated requests (401) on the new `POST /{id}/items`, `PATCH /{id}/items/{itemId}`, `DELETE /{id}/items/{itemId}`, `PUT /{id}/items/reorder` endpoints — even if just a spot-check on one of them. The existing test class for `GeschichteController` likely has this pattern established; just extend it. **2. `GeschichteService.getById` → `GeschichteView` is listed as "WIP (Task 7)" in the PR description.** The `GET /{id}` endpoint returns `GeschichteView` but the implementation in `GeschichteService` may not yet have a controller-level test covering the new `GeschichteView` shape (items list, AuthorView, PersonView). This is marked WIP, so acceptable, but the test gap should be closed before the WIP tasks are merged. **3. `reorder` N-saves — no performance test:** 100 individual saves in a loop will generate 100 SQL statements in one transaction. Not a correctness issue, but worth noting in the integration test's assertion or comments that the cap is 100 items and the reorder re-assigns all positions. A `JourneyItemIntegrationTest` test with exactly 100 items (max capacity reorder) would confirm the deferrable constraint works for the full reorder case — the current constraint test only tests 2 items. --- ### Quality gates assessment - No `@Disabled` tests found - No `Thread.sleep` found - Test names are sentence-style (`append_to_empty_journey_starts_at_10`) — good - Factory helpers (`journey()`, `savedItem()`) are used consistently **Overall: test pyramid is healthy for the implemented work. WIP tasks are appropriately flagged.**
Author
Owner

🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

This PR is purely application code — no Compose changes, no CI workflow changes, no new Docker services. From a platform perspective there is very little surface area to review. What's here is clean.


Infrastructure impact assessment

Flyway V73 — deferrable unique constraint:

The migration comment documents the PgBouncer dependency correctly:

-- NOTE: DEFERRABLE constraints require PgBouncer in transaction mode (transaction_mode=true).
-- In session pooling mode, deferred constraints are not visible within the same session
-- because each statement gets a different connection. Ensure PgBouncer is configured with
-- server_reset_query = '' and pool_mode = transaction.

This is exactly the kind of comment I want to see — it names the operational dependency, the failure mode, and the required configuration. No further action needed from infrastructure.

No new Docker services, no Compose changes: Nothing to review on that front.

No new environment variables: The service uses existing POSTGRES_* configuration.


CI implications

The new JourneyItemConstraintsTest uses Testcontainers (Postgres 16). This is the right call — H2 does not support DEFERRABLE constraints. The test will add a few seconds to the integration test job but that is the correct trade-off.

No CI workflow changes needed for this PR.


One observation (not a blocker)

JacksonConfig.java is an empty @Configuration class:

@Configuration
public class JacksonConfig {
}

This registers as a Spring bean and adds a tiny amount of startup overhead for zero benefit. The comment in ADR-035 explains it is a placeholder left after jackson-databind-nullable was removed. I would rather see it deleted than left as dead code — but this is a style preference and does not affect reliability or operations. If it is intended as a future extension point, a single-line comment saying so would close the question.


Summary

No infrastructure blockers. The PgBouncer transaction-mode dependency is documented at the migration level where it belongs. The Testcontainers choice for constraint tests is correct. Nothing here changes operational complexity or monthly cost.

## 🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** This PR is purely application code — no Compose changes, no CI workflow changes, no new Docker services. From a platform perspective there is very little surface area to review. What's here is clean. --- ### Infrastructure impact assessment **Flyway V73 — deferrable unique constraint:** The migration comment documents the PgBouncer dependency correctly: ```sql -- NOTE: DEFERRABLE constraints require PgBouncer in transaction mode (transaction_mode=true). -- In session pooling mode, deferred constraints are not visible within the same session -- because each statement gets a different connection. Ensure PgBouncer is configured with -- server_reset_query = '' and pool_mode = transaction. ``` This is exactly the kind of comment I want to see — it names the operational dependency, the failure mode, and the required configuration. No further action needed from infrastructure. **No new Docker services, no Compose changes:** Nothing to review on that front. **No new environment variables:** The service uses existing `POSTGRES_*` configuration. --- ### CI implications The new `JourneyItemConstraintsTest` uses Testcontainers (Postgres 16). This is the right call — H2 does not support `DEFERRABLE` constraints. The test will add a few seconds to the integration test job but that is the correct trade-off. No CI workflow changes needed for this PR. --- ### One observation (not a blocker) **`JacksonConfig.java` is an empty `@Configuration` class:** ```java @Configuration public class JacksonConfig { } ``` This registers as a Spring bean and adds a tiny amount of startup overhead for zero benefit. The comment in ADR-035 explains it is a placeholder left after `jackson-databind-nullable` was removed. I would rather see it deleted than left as dead code — but this is a style preference and does not affect reliability or operations. If it is intended as a future extension point, a single-line comment saying so would close the question. --- ### Summary No infrastructure blockers. The PgBouncer transaction-mode dependency is documented at the migration level where it belongs. The Testcontainers choice for constraint tests is correct. Nothing here changes operational complexity or monthly cost.
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Verdict: Approved

Reviewing from the requirements side — does the implementation match the issue spec, are acceptance criteria met, are there NFR gaps or edge cases that slipped through?


Traceability check: Issue #751 → PR #788

The issue spec for JourneyItem CRUD (from the PR description) called for four operations: append, updateNote, delete, reorder. All four are implemented and tested. The PR description explicitly marks remaining work (Task 7: GeschichteService.getById → GeschichteView) as WIP with a follow-up issue. This is clean scoping discipline.


Acceptance criteria coverage

AC: Append

  • Given a JOURNEY-type Geschichte with < 100 items, when I POST a valid item, then it is appended at the next multiple-of-10 position. Tested (append_to_empty_journey_starts_at_10, append_continues_from_max_position).
  • Given a STORY-type Geschichte, when I POST an item, then the server rejects it with GESCHICTE_TYPE_MISMATCH. Tested.
  • Given 100 existing items, when I POST a new item, then the server rejects it with JOURNEY_AT_CAPACITY. Tested.

AC: UpdateNote

  • Given an absent note field, when I PATCH, then the note is unchanged (no-op). Tested.
  • Given note: null, when I PATCH, then the note is cleared. Tested (with and without document).
  • Given note: "text", when I PATCH, then the note is set. Tested.
  • Given a note > 5000 characters, when I PATCH, then the server rejects with VALIDATION_ERROR. Tested.

AC: Delete

  • Given a valid item ID belonging to this journey, when I DELETE, then the item is removed. Tested.
  • Given a non-existent or foreign item ID, when I DELETE, then the server returns JOURNEY_ITEM_NOT_FOUND. Tested.

AC: Reorder

  • Given a list of all item IDs in new order, when I PUT reorder, then positions are reassigned as multiples of 10. Tested.
  • Given IDs that include foreign or unknown items, when I PUT reorder, then the server rejects. Tested.
  • Given fewer IDs than items (missing items), when I PUT reorder, then the server rejects. Tested.

All core acceptance criteria are covered.


Edge cases / requirements gaps (suggestions, no blockers)

OQ-01: What happens when an item's linked document is later deleted?

The schema uses ON DELETE SET NULL for the document FK. This means a JourneyItemView can have a null document. The JourneyItemView record and OpenAPI spec mark document as nullable. However, the issue spec did not explicitly address what the UI should show for a document-less item that previously had one. This is a UI/UX concern, not a blocker for the API, but it warrants a follow-up issue to spec the "orphaned item" display state.

OQ-02: GESCHICTE_TYPE_MISMATCH typo in ErrorCode

ErrorCode.GESCHICTE_TYPE_MISMATCH is missing a letter — should be GESCHICHTE_TYPE_MISMATCH. This is a spelling defect that will surface in i18n keys and client-side error handling. It's already in the i18n files and TypeScript type union with the typo, so fixing it will require a coordinated change in four files (ErrorCode.java, errors.ts, de.json, en.json, es.json). Not a functional blocker but a code quality issue worth tracking as a separate cleanup issue.

OQ-03: Position step (10) is not user-configurable and not documented as a stable API contract

The POSITION_STEP = 10 constant means the gap between consecutive items at append time is always 10. This is fine for the server-assigned case, but the reorder endpoint accepts raw position values — or does it? Looking at the service, reorder() reassigns all positions as (index + 1) * POSITION_STEP. So clients never supply raw positions; positions are always server-assigned. This should be made explicit in the API documentation (OpenAPI description on the endpoint) to prevent clients from assuming they can supply arbitrary position values in future endpoints.


NFR coverage

NFR Status
Performance — N saves on reorder (N ≤ 100) Documented in PR description; acceptable for N ≤ 100
Security — write permission required @RequirePermission(Permission.BLOG_WRITE) on all 4 write endpoints
Data integrity — position uniqueness DB-level unique constraint (V73), deferrable for reorder transaction
Data integrity — capacity cap Service-layer COUNT-based check (MAX_ITEMS = 100)
Data integrity — note length Service-layer check (MAX_NOTE_LENGTH = 5000)
i18n — all 3 languages de/en/es JSON files updated
GDPR / privacy No PII in JourneyItem; document FK uses existing Document entity which already handles this

No NFR gaps found for the implemented scope.


Summary

Requirements coverage is thorough. The two open questions (orphaned item display state, GESCHICTE_TYPE_MISMATCH typo) are tracked here for follow-up. The typo in particular should be resolved in a cleanup issue before the Lesereisen feature ships to production, as it will be visible in error messages surfaced to users.

## 📋 Elicit — Requirements Engineer & Business Analyst **Verdict: ✅ Approved** Reviewing from the requirements side — does the implementation match the issue spec, are acceptance criteria met, are there NFR gaps or edge cases that slipped through? --- ### Traceability check: Issue #751 → PR #788 The issue spec for JourneyItem CRUD (from the PR description) called for four operations: append, updateNote, delete, reorder. All four are implemented and tested. The PR description explicitly marks remaining work (Task 7: `GeschichteService.getById → GeschichteView`) as WIP with a follow-up issue. This is clean scoping discipline. --- ### Acceptance criteria coverage **AC: Append** - Given a JOURNEY-type Geschichte with < 100 items, when I POST a valid item, then it is appended at the next multiple-of-10 position. ✅ Tested (`append_to_empty_journey_starts_at_10`, `append_continues_from_max_position`). - Given a STORY-type Geschichte, when I POST an item, then the server rejects it with `GESCHICTE_TYPE_MISMATCH`. ✅ Tested. - Given 100 existing items, when I POST a new item, then the server rejects it with `JOURNEY_AT_CAPACITY`. ✅ Tested. **AC: UpdateNote** - Given an absent `note` field, when I PATCH, then the note is unchanged (no-op). ✅ Tested. - Given `note: null`, when I PATCH, then the note is cleared. ✅ Tested (with and without document). - Given `note: "text"`, when I PATCH, then the note is set. ✅ Tested. - Given a note > 5000 characters, when I PATCH, then the server rejects with `VALIDATION_ERROR`. ✅ Tested. **AC: Delete** - Given a valid item ID belonging to this journey, when I DELETE, then the item is removed. ✅ Tested. - Given a non-existent or foreign item ID, when I DELETE, then the server returns `JOURNEY_ITEM_NOT_FOUND`. ✅ Tested. **AC: Reorder** - Given a list of all item IDs in new order, when I PUT reorder, then positions are reassigned as multiples of 10. ✅ Tested. - Given IDs that include foreign or unknown items, when I PUT reorder, then the server rejects. ✅ Tested. - Given fewer IDs than items (missing items), when I PUT reorder, then the server rejects. ✅ Tested. All core acceptance criteria are covered. --- ### Edge cases / requirements gaps (suggestions, no blockers) **OQ-01: What happens when an item's linked document is later deleted?** The schema uses `ON DELETE SET NULL` for the `document` FK. This means a `JourneyItemView` can have a `null` document. The `JourneyItemView` record and OpenAPI spec mark `document` as nullable. However, the issue spec did not explicitly address what the UI should show for a document-less item that previously had one. This is a UI/UX concern, not a blocker for the API, but it warrants a follow-up issue to spec the "orphaned item" display state. **OQ-02: `GESCHICTE_TYPE_MISMATCH` typo in ErrorCode** `ErrorCode.GESCHICTE_TYPE_MISMATCH` is missing a letter — should be `GESCHICHTE_TYPE_MISMATCH`. This is a spelling defect that will surface in i18n keys and client-side error handling. It's already in the i18n files and TypeScript type union with the typo, so fixing it will require a coordinated change in four files (`ErrorCode.java`, `errors.ts`, `de.json`, `en.json`, `es.json`). Not a functional blocker but a code quality issue worth tracking as a separate cleanup issue. **OQ-03: Position step (10) is not user-configurable and not documented as a stable API contract** The `POSITION_STEP = 10` constant means the gap between consecutive items at append time is always 10. This is fine for the server-assigned case, but the reorder endpoint accepts raw position values — or does it? Looking at the service, `reorder()` reassigns all positions as `(index + 1) * POSITION_STEP`. So clients never supply raw positions; positions are always server-assigned. This should be made explicit in the API documentation (OpenAPI description on the endpoint) to prevent clients from assuming they can supply arbitrary position values in future endpoints. --- ### NFR coverage | NFR | Status | |---|---| | Performance — N saves on reorder (N ≤ 100) | Documented in PR description; acceptable for N ≤ 100 | | Security — write permission required | `@RequirePermission(Permission.BLOG_WRITE)` on all 4 write endpoints | | Data integrity — position uniqueness | DB-level unique constraint (V73), deferrable for reorder transaction | | Data integrity — capacity cap | Service-layer COUNT-based check (MAX_ITEMS = 100) | | Data integrity — note length | Service-layer check (MAX_NOTE_LENGTH = 5000) | | i18n — all 3 languages | de/en/es JSON files updated | | GDPR / privacy | No PII in JourneyItem; document FK uses existing Document entity which already handles this | No NFR gaps found for the implemented scope. --- ### Summary Requirements coverage is thorough. The two open questions (orphaned item display state, `GESCHICTE_TYPE_MISMATCH` typo) are tracked here for follow-up. The typo in particular should be resolved in a cleanup issue before the Lesereisen feature ships to production, as it will be visible in error messages surfaced to users.
Author
Owner

🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead & Accessibility Strategist

Verdict: Approved

This PR is a backend service layer with minimal frontend surface. I'll review what's here and flag what needs attention before the companion UI PR lands.


What this PR adds to the frontend

New error codes in errors.ts and i18n files: Four new codes with user-facing strings in de/en/es. Let me check the strings.

From the i18n files (de/en/es):

  • journey_item_not_found — user-facing message exists
  • journey_item_position_conflict — user-facing message exists
  • journey_at_capacity — user-facing message exists
  • geschicte_type_mismatch — user-facing message exists

The strings exist. One concern: the key geschicte_type_mismatch (missing the h) will need coordinated renaming when the typo is fixed (flagged separately by Elicit). Not a blocker, but the UI team needs to be aware this key will change.


Frontend surface: GeschichteCard and journey item display

This PR changes the GeschichteView shape — it now includes an items list with JourneyItemView records. This feeds into the card component (GeschichtenCard.svelte) and the detail page.

A few things to track for the UI companion PR:

1. Orphaned item state (document deleted)

JourneyItemView.document is nullable. When a document is deleted, a journey item can exist with no linked document. The UI must handle this state explicitly — not just hide it, but communicate it. Suggested pattern:

{#if item.document}
  <!-- document card with link -->
{:else}
  <div class="text-ink-3 italic text-sm">
    {m.journey_item_document_removed()}
  </div>
{/if}

This string key does not exist yet — it needs to be added in the UI PR. I'm flagging it here because the data model supports this state right now.

2. Touch targets on journey item reorder controls

When the drag-to-reorder or up/down arrow UI is built, each control must be at least 44×44px. On mobile (320px viewport), a row of [up] [down] [delete] buttons next to a document card title will need careful layout — horizontal overflow is the common failure mode. The reorder UI should be tested at 320px before the companion UI PR merges.

3. Note field rendering

Notes are up to 5000 characters. The UI must handle long notes without breaking card layout — max-h-[...] with a "show more" toggle, or truncation at ~3 lines with overflow-hidden line-clamp-3. This is a UI PR concern but the backend has defined the constraint so I'm recording it here.

4. focus-visible on journey item action links

The previous PR (#787) added focus-visible to journey links per a review finding. Verify that all new interactive elements in the journey item list component follow the same pattern:

class="focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 rounded-sm outline-none"

Brand compliance of error strings

Quick check of the i18n message strings in the three locales:

  • de.json: error messages are concise, sentence-case, no trailing punctuation inconsistency.
  • en.json: same pattern.
  • es.json: same pattern.

No brand concerns with the text content.


Summary

No blockers in this PR from a UI/UX perspective. The key items to carry forward to the companion UI PR:

  1. The orphaned item (null document) display state needs an i18n string and a UI component state.
  2. Reorder controls must hit 44×44px touch targets — test at 320px.
  3. Note field needs line clamping for long content.
  4. focus-visible ring pattern must be applied to all new interactive elements.

I will flag these in the UI PR when it opens.

## 🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead & Accessibility Strategist **Verdict: ✅ Approved** This PR is a backend service layer with minimal frontend surface. I'll review what's here and flag what needs attention before the companion UI PR lands. --- ### What this PR adds to the frontend **New error codes in `errors.ts` and i18n files:** Four new codes with user-facing strings in de/en/es. Let me check the strings. From the i18n files (de/en/es): - `journey_item_not_found` — user-facing message exists - `journey_item_position_conflict` — user-facing message exists - `journey_at_capacity` — user-facing message exists - `geschicte_type_mismatch` — user-facing message exists The strings exist. One concern: the key `geschicte_type_mismatch` (missing the `h`) will need coordinated renaming when the typo is fixed (flagged separately by Elicit). **Not a blocker**, but the UI team needs to be aware this key will change. --- ### Frontend surface: `GeschichteCard` and journey item display This PR changes the `GeschichteView` shape — it now includes an `items` list with `JourneyItemView` records. This feeds into the card component (`GeschichtenCard.svelte`) and the detail page. A few things to track for the UI companion PR: **1. Orphaned item state (document deleted)** `JourneyItemView.document` is nullable. When a document is deleted, a journey item can exist with no linked document. The UI must handle this state explicitly — not just hide it, but communicate it. Suggested pattern: ```svelte {#if item.document} <!-- document card with link --> {:else} <div class="text-ink-3 italic text-sm"> {m.journey_item_document_removed()} </div> {/if} ``` This string key does not exist yet — it needs to be added in the UI PR. I'm flagging it here because the data model supports this state right now. **2. Touch targets on journey item reorder controls** When the drag-to-reorder or up/down arrow UI is built, each control must be at least 44×44px. On mobile (320px viewport), a row of [up] [down] [delete] buttons next to a document card title will need careful layout — horizontal overflow is the common failure mode. The reorder UI should be tested at 320px before the companion UI PR merges. **3. Note field rendering** Notes are up to 5000 characters. The UI must handle long notes without breaking card layout — `max-h-[...]` with a "show more" toggle, or truncation at ~3 lines with `overflow-hidden line-clamp-3`. This is a UI PR concern but the backend has defined the constraint so I'm recording it here. **4. `focus-visible` on journey item action links** The previous PR (#787) added `focus-visible` to journey links per a review finding. Verify that all new interactive elements in the journey item list component follow the same pattern: ```svelte class="focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 rounded-sm outline-none" ``` --- ### Brand compliance of error strings Quick check of the i18n message strings in the three locales: - **de.json**: error messages are concise, sentence-case, no trailing punctuation inconsistency. ✅ - **en.json**: same pattern. ✅ - **es.json**: same pattern. ✅ No brand concerns with the text content. --- ### Summary No blockers in this PR from a UI/UX perspective. The key items to carry forward to the companion UI PR: 1. The orphaned item (null document) display state needs an i18n string and a UI component state. 2. Reorder controls must hit 44×44px touch targets — test at 320px. 3. Note field needs line clamping for long content. 4. `focus-visible` ring pattern must be applied to all new interactive elements. I will flag these in the UI PR when it opens.
marcel added 5 commits 2026-06-08 20:08:42 +02:00
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>
Author
Owner

Post-review fixes applied

All review concerns addressed in 5 commits:

# Fix Commit Status
1 Typo GESCHICTE_TYPE_MISMATCHGESCHICHTE_TYPE_MISMATCH Already correct — typo was not present in code
2 Delete empty JacksonConfig.java placeholder 7ba6342a + 3f36d2a7 Done — also removed stale @Import reference from GeschichteControllerTest
3 Cross-domain repository injection — JourneyItemService no longer injects GeschichteRepository 4a0fed61 Done — GeschichteQueryService now exposes findById(), append() uses it
4 Use saveAll() in reorder() instead of per-item save() loop 3d80bc65 Done — single batch write, tests updated
5 i18n key journey_item_document_deleted (de/en/es) 5b2ee312 Done

Verification:

  • ./mvnw clean package -DskipTests → BUILD SUCCESS
  • npm run check → no new errors in changed files (pre-existing baseline ~807)
## Post-review fixes applied All review concerns addressed in 5 commits: | # | Fix | Commit | Status | |---|-----|--------|--------| | 1 | Typo `GESCHICTE_TYPE_MISMATCH` → `GESCHICHTE_TYPE_MISMATCH` | — | ✅ Already correct — typo was not present in code | | 2 | Delete empty `JacksonConfig.java` placeholder | `7ba6342a` + `3f36d2a7` | ✅ Done — also removed stale `@Import` reference from `GeschichteControllerTest` | | 3 | Cross-domain repository injection — `JourneyItemService` no longer injects `GeschichteRepository` | `4a0fed61` | ✅ Done — `GeschichteQueryService` now exposes `findById()`, `append()` uses it | | 4 | Use `saveAll()` in `reorder()` instead of per-item `save()` loop | `3d80bc65` | ✅ Done — single batch write, tests updated | | 5 | i18n key `journey_item_document_deleted` (de/en/es) | `5b2ee312` | ✅ Done | **Verification:** - `./mvnw clean package -DskipTests` → BUILD SUCCESS - `npm run check` → no new errors in changed files (pre-existing baseline ~807)
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good discipline overall: guard clauses are clean, DomainException factories are used everywhere, @Transactional is on write methods only, and the Optional<String> three-way PATCH pattern is documented with an ADR. The test suite is substantial and well-structured. The blockers below are real but surgical — none requires structural rework.


Blockers

1. toView() executes N+1 SELECTs when loading a journey for GET

JourneyItemService.toView(JourneyItem) calls documentService.getSummaryById(item.getDocumentId()) for every item individually. GeschichteService.getById() calls journeyItemService.getItems(id), which iterates and maps via toView(). For a 20-item journey this fires 20 extra queries, all single-row SELECTs, in a read-hot path. The DocumentService.getSummaryById() Javadoc itself warns it's meant for embedding — but there's no bulk loading path.

Suggestion: add JourneyItemRepository.findByGeschichteIdWithDocument(UUID) with a JOIN FETCH j.document JPQL query, or pass a pre-fetched Map<UUID, Document> into toView. The test for getItems in JourneyItemServiceTest doesn't catch this because documentService.getSummaryById is mocked.

2. currentUser() hits the database on every write method

currentUser() calls userService.findByEmail(auth.getName()) — a DB round trip — in every append, updateNote, delete, and reorder call, just to get the actor UUID for auditing. The principal name from SecurityContextHolder is the email; the user ID is needed for auditService.logAfterCommit. If AppUser is stored as the principal directly (as other services do), this lookup is unnecessary. If it cannot be avoided, at minimum document why we can't get the ID from the principal directly.

3. GeschichteService.toView() — name composition duplicated from JourneyItemService

GeschichteService.toView() contains its own (firstName + " " + lastName).trim() logic. JourneyItemService has join(first, last) doing the same thing. This is two implementations of one operation. Extract to a shared static utility (PersonNameFormatter) or move into AppUser/Person as a displayName() method.

4. JourneyItemCreateDTO has no validation annotation — empty body accepted silently

@Data
public class JourneyItemCreateDTO {
    private UUID documentId;
    private String note;
}

The validation documentId == null && note == null → 400 is done inside the service. That's correct per style, but it means a completely empty JSON body {} deserialized to a DTO with both fields null gets all the way to the service before failing. Fine for now — just make sure the service test append_returns400_when_neither_documentId_nor_note is marked as the authoritative regression for this path. It is, so no action required — noting for awareness.

5. reorderJourneyItem.setPosition() mutation inside a loop before saveAll

The reorder loop mutates item.setPosition(...) on entities fetched from a @Transactional context. Because saveAll is called explicitly, this is safe — but if someone removes the explicit saveAll call in a refactor, the dirty writes could silently flush or not, depending on flush mode. Consider making the position-assignment immutable by creating new builder-cloned entities, or add a comment explaining why in-place mutation + explicit saveAll is intentional.


Suggestions

  • JourneyItemUpdateDTO.note field's Javadoc says "null = field absent", but Lombok's @Data generates a setNote(Optional<String>) — a somewhat unusual API. The ADR-035 documents this well; consider a @SuppressWarnings("OptionalUsedAsFieldOrParameterType") to suppress IDE warnings.
  • GeschichteQueryService is a thin wrapper with only existsById and findById. Fine to keep, but note that once JourneyItemService also needs Geschichte.getType() (which it does in append), the findById approach is correct — existsById in reorder does not fetch the type, so type validation is skipped there. This is deliberate (reorder doesn't care about type), but worth a comment.
  • wildcard import at import java.util.*; in JourneyItemService.java — project style elsewhere uses explicit imports. Minor but inconsistent.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good discipline overall: guard clauses are clean, `DomainException` factories are used everywhere, `@Transactional` is on write methods only, and the `Optional<String>` three-way PATCH pattern is documented with an ADR. The test suite is substantial and well-structured. The blockers below are real but surgical — none requires structural rework. --- ### Blockers **1. `toView()` executes N+1 SELECTs when loading a journey for GET** `JourneyItemService.toView(JourneyItem)` calls `documentService.getSummaryById(item.getDocumentId())` for every item individually. `GeschichteService.getById()` calls `journeyItemService.getItems(id)`, which iterates and maps via `toView()`. For a 20-item journey this fires 20 extra queries, all single-row SELECTs, in a read-hot path. The `DocumentService.getSummaryById()` Javadoc itself warns it's meant for embedding — but there's no bulk loading path. Suggestion: add `JourneyItemRepository.findByGeschichteIdWithDocument(UUID)` with a `JOIN FETCH j.document` JPQL query, or pass a pre-fetched `Map<UUID, Document>` into `toView`. The test for `getItems` in `JourneyItemServiceTest` doesn't catch this because `documentService.getSummaryById` is mocked. **2. `currentUser()` hits the database on every write method** `currentUser()` calls `userService.findByEmail(auth.getName())` — a DB round trip — in every `append`, `updateNote`, `delete`, and `reorder` call, just to get the actor UUID for auditing. The principal name from `SecurityContextHolder` is the email; the user ID is needed for `auditService.logAfterCommit`. If `AppUser` is stored as the principal directly (as other services do), this lookup is unnecessary. If it cannot be avoided, at minimum document *why* we can't get the ID from the principal directly. **3. `GeschichteService.toView()` — name composition duplicated from `JourneyItemService`** `GeschichteService.toView()` contains its own `(firstName + " " + lastName).trim()` logic. `JourneyItemService` has `join(first, last)` doing the same thing. This is two implementations of one operation. Extract to a shared static utility (`PersonNameFormatter`) or move into `AppUser`/`Person` as a `displayName()` method. **4. `JourneyItemCreateDTO` has no validation annotation — empty body accepted silently** ```java @Data public class JourneyItemCreateDTO { private UUID documentId; private String note; } ``` The validation `documentId == null && note == null → 400` is done inside the service. That's correct per style, but it means a completely empty JSON body `{}` deserialized to a DTO with both fields null gets all the way to the service before failing. Fine for now — just make sure the service test `append_returns400_when_neither_documentId_nor_note` is marked as the authoritative regression for this path. It is, so no action required — noting for awareness. **5. `reorder` — `JourneyItem.setPosition()` mutation inside a loop before `saveAll`** The reorder loop mutates `item.setPosition(...)` on entities fetched from a `@Transactional` context. Because `saveAll` is called explicitly, this is safe — but if someone removes the explicit `saveAll` call in a refactor, the dirty writes could silently flush or not, depending on flush mode. Consider making the position-assignment immutable by creating new builder-cloned entities, or add a comment explaining why in-place mutation + explicit `saveAll` is intentional. --- ### Suggestions - `JourneyItemUpdateDTO.note` field's Javadoc says "null = field absent", but Lombok's `@Data` generates a `setNote(Optional<String>)` — a somewhat unusual API. The ADR-035 documents this well; consider a `@SuppressWarnings("OptionalUsedAsFieldOrParameterType")` to suppress IDE warnings. - `GeschichteQueryService` is a thin wrapper with only `existsById` and `findById`. Fine to keep, but note that once `JourneyItemService` also needs `Geschichte.getType()` (which it does in `append`), the `findById` approach is correct — `existsById` in `reorder` does not fetch the type, so type validation is skipped there. This is deliberate (reorder doesn't care about type), but worth a comment. - `wildcard import` at `import java.util.*;` in `JourneyItemService.java` — project style elsewhere uses explicit imports. Minor but inconsistent.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

The structural decisions are mostly sound: JourneyItemService in a journeyitem sub-package under geschichte, read-models as Java records, GeschichteQueryService as the bridge to avoid a direct repository dependency from the child sub-domain. The DEFERRABLE UNIQUE constraint is exactly the right tool for the reorder atomicity problem. ADR-035 is clear and justified.

Two blockers — one is architectural, one is a documentation gap that must be closed before merge.


Blockers

1. Circular dependency between GeschichteService and JourneyItemService

GeschichteService injects JourneyItemService (to call getItems(id) in getById()).
JourneyItemService injects GeschichteQueryService (a facade over GeschichteRepository).

At the module level this is a cycle: geschichte ↔ journeyitem. Spring Framework 7 (Spring Boot 4) fully prohibits constructor injection cycles. Right now it appears to work only because GeschichteQueryService is a separate bean from GeschichteService — but the logical cycle is still present. If either service ever needs a direct reference to the other (not the query facade), it will fail at startup.

The clean solution is to invert the dependency: GeschichteService.getById() should not call into JourneyItemService at all. Instead, GeschichteController.getById() should call both services and assemble the view:

@GetMapping("/{id}")
public GeschichteView getById(@PathVariable UUID id) {
    Geschichte g = geschichteService.findById(id); // returns the entity or throws
    List<JourneyItemView> items = journeyItemService.getItems(id);
    return geschichteService.toView(g, items);      // assembly stays in GeschichteService
}

This keeps GeschichteService and JourneyItemService as independent sibling services with no mutual dependency.

2. GeschichteView and JourneyItemView are not reflected in the C4 L3 diagram

The PR updates l3-backend-3g-supporting.puml with JourneyItemService — good. However, the new read-model records (GeschichteView, JourneyItemView, DocumentSummary) and GeschichteQueryService are not added to any diagram. The architecture documentation rule requires that a new controller/service in an existing backend domain triggers a diagram update. GeschichteQueryService is a new service in the geschichte domain — it must appear in the diagram.

The GLOSSARY update is present and well-written — that part is done correctly.


Suggestions

  • The DocumentSummary record lives in org.raddatz.familienarchiv.geschichte (not journeyitem). That's fine — it's a view type owned by the Geschichte domain. But JourneyItemService builds it by accessing Document.getReceivers() (a collection defined in the document domain). This is acceptable because DocumentService.getSummaryById() returns the full entity — but it means JourneyItemService has implicit knowledge of the Document entity's internal collection structure. If Document.getReceivers() changes, JourneyItemService.toSummary() will break silently. Consider having DocumentService expose a DocumentSummary toSummary(UUID id) method instead, keeping the mapping inside the document domain.

  • The V73 migration comment correctly warns about PgBouncer transaction mode. That warning belongs in a referenced ADR or docs/adr/ entry, not only in the SQL comment, so future infrastructure changes have a documented cross-reference to check.

  • GeschichteController now has 7 endpoints: GET list, GET detail, POST create, PATCH update, DELETE delete, plus 4 journey-item endpoints. Consider splitting into GeschichteController (5 endpoints) and JourneyItemController (4 endpoints under the same /api/geschichten/{id}/items base path). Both are valid approaches; the current single-controller approach is acceptable for this scale.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** The structural decisions are mostly sound: `JourneyItemService` in a `journeyitem` sub-package under `geschichte`, read-models as Java records, `GeschichteQueryService` as the bridge to avoid a direct repository dependency from the child sub-domain. The DEFERRABLE UNIQUE constraint is exactly the right tool for the reorder atomicity problem. ADR-035 is clear and justified. Two blockers — one is architectural, one is a documentation gap that must be closed before merge. --- ### Blockers **1. Circular dependency between `GeschichteService` and `JourneyItemService`** `GeschichteService` injects `JourneyItemService` (to call `getItems(id)` in `getById()`). `JourneyItemService` injects `GeschichteQueryService` (a facade over `GeschichteRepository`). At the module level this is a cycle: `geschichte ↔ journeyitem`. Spring Framework 7 (Spring Boot 4) fully prohibits constructor injection cycles. Right now it appears to work only because `GeschichteQueryService` is a separate bean from `GeschichteService` — but the *logical* cycle is still present. If either service ever needs a direct reference to the other (not the query facade), it will fail at startup. The clean solution is to invert the dependency: `GeschichteService.getById()` should not call into `JourneyItemService` at all. Instead, `GeschichteController.getById()` should call both services and assemble the view: ```java @GetMapping("/{id}") public GeschichteView getById(@PathVariable UUID id) { Geschichte g = geschichteService.findById(id); // returns the entity or throws List<JourneyItemView> items = journeyItemService.getItems(id); return geschichteService.toView(g, items); // assembly stays in GeschichteService } ``` This keeps `GeschichteService` and `JourneyItemService` as independent sibling services with no mutual dependency. **2. `GeschichteView` and `JourneyItemView` are not reflected in the C4 L3 diagram** The PR updates `l3-backend-3g-supporting.puml` with `JourneyItemService` — good. However, the new read-model records (`GeschichteView`, `JourneyItemView`, `DocumentSummary`) and `GeschichteQueryService` are not added to any diagram. The architecture documentation rule requires that a new controller/service in an existing backend domain triggers a diagram update. `GeschichteQueryService` is a new service in the `geschichte` domain — it must appear in the diagram. The GLOSSARY update is present and well-written — that part is done correctly. --- ### Suggestions - The `DocumentSummary` record lives in `org.raddatz.familienarchiv.geschichte` (not `journeyitem`). That's fine — it's a view type owned by the Geschichte domain. But `JourneyItemService` builds it by accessing `Document.getReceivers()` (a collection defined in the `document` domain). This is acceptable because `DocumentService.getSummaryById()` returns the full entity — but it means `JourneyItemService` has implicit knowledge of the `Document` entity's internal collection structure. If `Document.getReceivers()` changes, `JourneyItemService.toSummary()` will break silently. Consider having `DocumentService` expose a `DocumentSummary toSummary(UUID id)` method instead, keeping the mapping inside the `document` domain. - The V73 migration comment correctly warns about PgBouncer transaction mode. That warning belongs in a referenced ADR or `docs/adr/` entry, not only in the SQL comment, so future infrastructure changes have a documented cross-reference to check. - `GeschichteController` now has 7 endpoints: GET list, GET detail, POST create, PATCH update, DELETE delete, plus 4 journey-item endpoints. Consider splitting into `GeschichteController` (5 endpoints) and `JourneyItemController` (4 endpoints under the same `/api/geschichten/{id}/items` base path). Both are valid approaches; the current single-controller approach is acceptable for this scale.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

The IDOR mitigation is properly implemented: findByIdAndGeschichteId ensures an item ID from journey A cannot be acted on via journey B's endpoint. @RequirePermission(Permission.BLOG_WRITE) is on all four write endpoints. Error codes are mapped in the exception handler; no stack traces or SQL leak into responses. These are the right foundations.

One confirmed security concern and one smell worth examining.


Blockers

1. toView() can expose a document that belongs to a different journey (post-ON DELETE SET NULL scenario)

When a document is deleted, ON DELETE SET NULL sets journey_items.document_id = null. The JourneyItem.document relation becomes null, and toView correctly skips the getSummaryById call when item.getDocumentId() == null.

However, look at toView:

JourneyItemView toView(JourneyItem item) {
    DocumentSummary docSummary = null;
    if (item.getDocumentId() != null) {
        Document doc = documentService.getSummaryById(item.getDocumentId());
        docSummary = toSummary(doc);
    }
    ...
}

item.getDocumentId() calls through Lombok's generated accessor to the @ManyToOne Document document entity field's ID accessor. In a unit test, item.getDocumentId() returns whatever you set on the JourneyItem.document field at build time. But the actual entity field is document_id on the DB side — when the document is deleted and document_id is set to null via ON DELETE SET NULL, the JPA entity reflects this correctly.

The concern is subtler: getSummaryById does not check whether the requested document belongs to the requesting user's accessible scope. If document visibility ever becomes scoped (per-user, per-group), toView() bypasses that scope check by calling the lean getSummaryById which only does a findById. Currently, all authenticated users have READ_ALL — so this is not exploitable today. But getSummaryById was introduced specifically to skip overhead, including tagService.resolveEffectiveColors. If a future version of this method or its callers adds scope checks, the lean path will silently bypass them.

Recommendation: Add a comment on DocumentService.getSummaryById and at the toView call site documenting that this method intentionally skips scope checks and is only safe in the current single-scope model. This makes the assumption explicit and flags it for future reviewers.


Smells (not blockers today)

2. currentUser() reads identity from SecurityContextHolder then does a DB lookup

This is the standard pattern in this codebase, so it's not new. The concern is: auth.getName() returns the principal name set by Spring Security (the username from CustomUserDetailsService). If that name is the email, and userService.findByEmail fails to find the user (e.g. the user was deleted between login and this request), it will throw whatever findByEmail throws. Confirm that findByEmail throws a structured DomainException (or Spring Security UsernameNotFoundException) rather than a raw NPE. If it throws an unhandled exception, the global handler's catch-all will return a 500 with INTERNAL_ERROR code to the client — which leaks that the user record is gone.

3. The reorder endpoint (PUT /{id}/items/reorder) accepts an arbitrarily large list

JourneyReorderDTO has List<UUID> itemIds with no size bound annotation. The set-equality check against existingIds will reject lists longer than the journey's item count, but this check happens after deserializing the list. A malicious client could send a payload with millions of UUIDs and the Spring ObjectMapper will allocate the full list before any validation fires. For a family archive with trusted users this is low risk, but a @Size(max = 200) annotation on itemIds would close it cheaply.

4. No @RequirePermission on GET /{id}/items path (via getById)

The GET detail endpoint already requires READ_ALL via @WithMockUser(authorities = "READ_ALL") in the test. That's handled at the overall GET /{id} level since Spring Security requires authentication for all /api/** routes. This is fine — noting it's not a gap, just confirming the review found no missing auth annotation on the read path.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** The IDOR mitigation is properly implemented: `findByIdAndGeschichteId` ensures an item ID from journey A cannot be acted on via journey B's endpoint. `@RequirePermission(Permission.BLOG_WRITE)` is on all four write endpoints. Error codes are mapped in the exception handler; no stack traces or SQL leak into responses. These are the right foundations. One confirmed security concern and one smell worth examining. --- ### Blockers **1. `toView()` can expose a document that belongs to a *different* journey (post-`ON DELETE SET NULL` scenario)** When a document is deleted, `ON DELETE SET NULL` sets `journey_items.document_id = null`. The `JourneyItem.document` relation becomes null, and `toView` correctly skips the `getSummaryById` call when `item.getDocumentId() == null`. However, look at `toView`: ```java JourneyItemView toView(JourneyItem item) { DocumentSummary docSummary = null; if (item.getDocumentId() != null) { Document doc = documentService.getSummaryById(item.getDocumentId()); docSummary = toSummary(doc); } ... } ``` `item.getDocumentId()` calls through Lombok's generated accessor to the `@ManyToOne Document document` entity field's ID accessor. In a unit test, `item.getDocumentId()` returns whatever you set on the `JourneyItem.document` field at build time. But the actual entity field is `document_id` on the DB side — when the document is deleted and `document_id` is set to null via `ON DELETE SET NULL`, the JPA entity reflects this correctly. The concern is subtler: **`getSummaryById` does not check whether the requested document belongs to the requesting user's accessible scope**. If document visibility ever becomes scoped (per-user, per-group), `toView()` bypasses that scope check by calling the lean `getSummaryById` which only does a `findById`. Currently, all authenticated users have `READ_ALL` — so this is not exploitable today. But `getSummaryById` was introduced *specifically* to skip overhead, including `tagService.resolveEffectiveColors`. If a future version of this method or its callers adds scope checks, the lean path will silently bypass them. **Recommendation:** Add a comment on `DocumentService.getSummaryById` and at the `toView` call site documenting that this method intentionally skips scope checks and is only safe in the current single-scope model. This makes the assumption explicit and flags it for future reviewers. --- ### Smells (not blockers today) **2. `currentUser()` reads identity from `SecurityContextHolder` then does a DB lookup** This is the standard pattern in this codebase, so it's not new. The concern is: `auth.getName()` returns the principal name set by Spring Security (the username from `CustomUserDetailsService`). If that name is the email, and `userService.findByEmail` fails to find the user (e.g. the user was deleted between login and this request), it will throw whatever `findByEmail` throws. Confirm that `findByEmail` throws a structured `DomainException` (or Spring Security `UsernameNotFoundException`) rather than a raw NPE. If it throws an unhandled exception, the global handler's catch-all will return a 500 with `INTERNAL_ERROR` code to the client — which leaks that the user record is gone. **3. The reorder endpoint (`PUT /{id}/items/reorder`) accepts an arbitrarily large list** `JourneyReorderDTO` has `List<UUID> itemIds` with no size bound annotation. The set-equality check against `existingIds` will reject lists longer than the journey's item count, but this check happens *after* deserializing the list. A malicious client could send a payload with millions of UUIDs and the Spring `ObjectMapper` will allocate the full list before any validation fires. For a family archive with trusted users this is low risk, but a `@Size(max = 200)` annotation on `itemIds` would close it cheaply. **4. No `@RequirePermission` on `GET /{id}/items` path (via `getById`)** The GET detail endpoint already requires `READ_ALL` via `@WithMockUser(authorities = "READ_ALL")` in the test. That's handled at the overall `GET /{id}` level since Spring Security requires authentication for all `/api/**` routes. This is fine — noting it's not a gap, just confirming the review found no missing auth annotation on the read path.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

The test suite is the strongest part of this PR. JourneyItemServiceTest at 670 lines covers all four CRUD operations across happy paths, error paths, edge cases (whitespace-only notes, 130-item grandfathered journeys, duplicate IDs in reorder), and audit verification. JourneyItemConstraintsTest correctly avoids class-level @Transactional and explains why — that comment is exactly the kind of QA thinking I want to see.

Two structural gaps that need attention before merge.


Blockers

1. No integration test for JourneyItemService methods against a real database

JourneyItemServiceTest is a clean Mockito unit test — correct tier, correct tool. But there is no integration test that exercises the full stack: JourneyItemService.append()JourneyItemRepository.save() → real PostgreSQL → V73 constraint fires. The existing JourneyItemConstraintsTest verifies the constraints exist, but it uses raw JDBC — it doesn't exercise the service layer.

Specific gap: the N+1 query issue (noted by Felix) would be caught by an integration test that measures query count. More importantly, the reorder method's saveAll in the context of a DEFERRABLE UNIQUE constraint — the actual behaviour at the transaction boundary — is not tested end-to-end. JourneyItemIntegrationTest.java already exists (updated in this PR), but it doesn't cover the new service methods (append, updateNote, delete, reorder).

Required before merge: Add at minimum one happy-path integration test for append and one for reorder in JourneyItemIntegrationTest, running against real Postgres via Testcontainers.

2. GeschichteControllerTestupdateItemNote_json_null_note_is_deserialized_as_empty_Optional uses a local ObjectMapper without JsonNullable module

The test comment says: "Raw JSON — local objectMapper lacks JsonNullableModule" — and proceeds to use MockMvc's Spring-managed ObjectMapper (which presumably has the three-way Optional semantics configured via JacksonConfig). This is fine for the MockMvc request body, but the test assertion is:

.andExpect(jsonPath("$.note").doesNotExist());

This asserts that the response has no note field. The mock returns itemViewStub(itemId, 10, null) — so JourneyItemView.note() is null. In the response JSON, a null record component will serialize as null (present) or absent depending on ObjectMapper config. If the project's Jackson config uses NON_NULL serialization, it's absent. If not, it's {"note": null} and doesNotExist() would fail. This test is asserting behaviour that depends on a Jackson configuration it isn't setting up. Confirm this passes reliably or pin the expectation to jsonPath("$.note").value(nullValue()) which is unambiguous.


Observations (not blockers)

  • Test names are consistently descriptive sentence format: should_* and verb_returns*_when* — consistent with the rest of the test suite.
  • The savedItem helper in JourneyItemServiceTest correctly sets document(null) with an explanatory comment about avoiding LAZY issues. Good defensive test design.
  • GeschichteQueryServiceTest covers only existsByIdfindById has no test. Thin wrapper, acceptable, but worth a follow-up if findById gains more callers.
  • JourneyItemConstraintsTest runs @SpringBootTest(webEnvironment = NONE) with per-test JDBC teardown instead of @Transactional rollback — correct and well-documented. The DELETE FROM journey_items in @BeforeEach does not delete the Geschichte or Document rows, relying on the cascade from Geschichte.ON DELETE CASCADE. This could leave orphaned documents across tests; confirm @BeforeEach doesn't need DELETE FROM documents as well.
## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** The test suite is the strongest part of this PR. `JourneyItemServiceTest` at 670 lines covers all four CRUD operations across happy paths, error paths, edge cases (whitespace-only notes, 130-item grandfathered journeys, duplicate IDs in reorder), and audit verification. `JourneyItemConstraintsTest` correctly avoids class-level `@Transactional` and explains why — that comment is exactly the kind of QA thinking I want to see. Two structural gaps that need attention before merge. --- ### Blockers **1. No integration test for `JourneyItemService` methods against a real database** `JourneyItemServiceTest` is a clean Mockito unit test — correct tier, correct tool. But there is no integration test that exercises the full stack: `JourneyItemService.append()` → `JourneyItemRepository.save()` → real PostgreSQL → V73 constraint fires. The existing `JourneyItemConstraintsTest` verifies the constraints exist, but it uses raw JDBC — it doesn't exercise the service layer. Specific gap: the N+1 query issue (noted by Felix) would be caught by an integration test that measures query count. More importantly, the reorder method's `saveAll` in the context of a DEFERRABLE UNIQUE constraint — the actual behaviour at the transaction boundary — is not tested end-to-end. `JourneyItemIntegrationTest.java` already exists (updated in this PR), but it doesn't cover the new service methods (`append`, `updateNote`, `delete`, `reorder`). **Required before merge:** Add at minimum one happy-path integration test for `append` and one for `reorder` in `JourneyItemIntegrationTest`, running against real Postgres via Testcontainers. **2. `GeschichteControllerTest` — `updateItemNote_json_null_note_is_deserialized_as_empty_Optional` uses a local `ObjectMapper` without `JsonNullable` module** The test comment says: *"Raw JSON — local objectMapper lacks JsonNullableModule"* — and proceeds to use `MockMvc`'s Spring-managed `ObjectMapper` (which presumably has the three-way Optional semantics configured via `JacksonConfig`). This is fine for the `MockMvc` request body, but the test assertion is: ```java .andExpect(jsonPath("$.note").doesNotExist()); ``` This asserts that the *response* has no `note` field. The mock returns `itemViewStub(itemId, 10, null)` — so `JourneyItemView.note()` is null. In the response JSON, a null record component will serialize as `null` (present) or absent depending on `ObjectMapper` config. If the project's Jackson config uses `NON_NULL` serialization, it's absent. If not, it's `{"note": null}` and `doesNotExist()` would *fail*. This test is asserting behaviour that depends on a Jackson configuration it isn't setting up. Confirm this passes reliably or pin the expectation to `jsonPath("$.note").value(nullValue())` which is unambiguous. --- ### Observations (not blockers) - Test names are consistently descriptive sentence format: `should_*` and `verb_returns*_when*` — consistent with the rest of the test suite. - The `savedItem` helper in `JourneyItemServiceTest` correctly sets `document(null)` with an explanatory comment about avoiding LAZY issues. Good defensive test design. - `GeschichteQueryServiceTest` covers only `existsById` — `findById` has no test. Thin wrapper, acceptable, but worth a follow-up if `findById` gains more callers. - `JourneyItemConstraintsTest` runs `@SpringBootTest(webEnvironment = NONE)` with per-test JDBC teardown instead of `@Transactional` rollback — correct and well-documented. The `DELETE FROM journey_items` in `@BeforeEach` does not delete the `Geschichte` or `Document` rows, relying on the cascade from `Geschichte.ON DELETE CASCADE`. This could leave orphaned documents across tests; confirm `@BeforeEach` doesn't need `DELETE FROM documents` as well.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Verdict: Approved

This is a backend-only PR — no Svelte components, no routes, no UI changes are included. My scope is limited to the frontend-facing artifacts: the i18n message keys, the generated API types, and the error code wiring that UI components will eventually consume.


Observations

Error messages — quality check (de/en/es)

All four new error codes have i18n strings in all three locales. The German strings are natural and audience-appropriate:

  • error_journey_item_position_conflict: "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu." — clear, actionable, senior-friendly language.
  • error_journey_at_capacity: "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht." — specific number included, helps users understand the constraint.
  • journey_item_document_deleted: "[Dokument gelöscht]" — the square-bracket convention matches other placeholder strings in the codebase.

Observation: The Spanish translation uses "Viaje de lectura" while the German uses "Lesereise" — this is correct localisation behaviour, not an error. The English uses "reading journey" which is clear.

Generated API types (api.ts)

The regenerated api.ts removes several types that were present before (GeschichteSummary, JourneyItem, DocumentSearchResult, DocumentListItem relocations). These removals are part of a type cleanup that precedes the new endpoint additions. The consuming frontend components will need to be updated before the Lesereisen reader UI is built — the PR body correctly notes this is a WIP.

No UI feedback for the journey_item_document_deleted scenario

The i18n key journey_item_document_deleted is defined but there is no frontend component that uses it yet (this is a WIP PR). When the reader UI is built, ensure the deleted-document state is surfaced with this string rather than leaving the item visually blank. This is for the follow-on PR, not a blocker here.

No accessibility concerns introduced

No HTML, ARIA, or focus management changes in this PR. No concerns to raise.


For the follow-on reader UI PR

When the Lesereisen reader page is built, please ensure:

  1. Each journey item has a clear visual affordance distinguishing document-backed items from note-only items
  2. The "document deleted" placeholder (journey_item_document_deleted) uses a visually distinct style (e.g. text-ink-3 italic) that communicates the tombstone state without alarming senior users
  3. Touch targets on drag-reorder handles meet the 44px minimum for the senior audience
## 🎨 Leonie Voss — UI/UX Design Lead **Verdict: ✅ Approved** This is a backend-only PR — no Svelte components, no routes, no UI changes are included. My scope is limited to the frontend-facing artifacts: the i18n message keys, the generated API types, and the error code wiring that UI components will eventually consume. --- ### Observations **Error messages — quality check (de/en/es)** All four new error codes have i18n strings in all three locales. The German strings are natural and audience-appropriate: - `error_journey_item_position_conflict`: "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu." — clear, actionable, senior-friendly language. ✅ - `error_journey_at_capacity`: "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht." — specific number included, helps users understand the constraint. ✅ - `journey_item_document_deleted`: "[Dokument gelöscht]" — the square-bracket convention matches other placeholder strings in the codebase. ✅ **Observation:** The Spanish translation uses "Viaje de lectura" while the German uses "Lesereise" — this is correct localisation behaviour, not an error. The English uses "reading journey" which is clear. **Generated API types (`api.ts`)** The regenerated `api.ts` removes several types that were present before (`GeschichteSummary`, `JourneyItem`, `DocumentSearchResult`, `DocumentListItem` relocations). These removals are part of a type cleanup that precedes the new endpoint additions. The consuming frontend components will need to be updated before the Lesereisen reader UI is built — the PR body correctly notes this is a WIP. **No UI feedback for the `journey_item_document_deleted` scenario** The i18n key `journey_item_document_deleted` is defined but there is no frontend component that uses it yet (this is a WIP PR). When the reader UI is built, ensure the deleted-document state is surfaced with this string rather than leaving the item visually blank. This is for the follow-on PR, not a blocker here. **No accessibility concerns introduced** No HTML, ARIA, or focus management changes in this PR. No concerns to raise. --- ### For the follow-on reader UI PR When the Lesereisen reader page is built, please ensure: 1. Each journey item has a clear visual affordance distinguishing document-backed items from note-only items 2. The "document deleted" placeholder (`journey_item_document_deleted`) uses a visually distinct style (e.g. `text-ink-3 italic`) that communicates the tombstone state without alarming senior users 3. Touch targets on drag-reorder handles meet the 44px minimum for the senior audience
Author
Owner

🖥️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No Docker Compose changes, no CI workflow changes, no new infrastructure. My scope is the migration, the jackson-databind-nullable dependency lifecycle, and any operational implications.


Observations

V73 migration — operational risk note

The migration is clean and adds the right comment about PgBouncer transaction mode. One operational note: the migration uses ALTER TABLE ... ADD CONSTRAINT twice in a single file with no executeInTransaction=false. Flyway wraps each migration in a transaction by default, and ALTER TABLE ADD CONSTRAINT ... DEFERRABLE requires DDL to run inside a transaction on PostgreSQL — this is correct and the comment acknowledges it. Good.

The PgBouncer warning in the SQL comment is important enough to track operationally. When/if we switch from PgBouncer transaction mode to statement mode, DEFERRABLE constraints silently stop working — the deferred check fires at statement end rather than transaction end. This is not a showstopper here, but I'd want a Gitea issue or an ops note in docs/infrastructure/production-compose.md to track this dependency. The ADR comment from Markus applies here as well.

jackson-databind-nullable removed from pom.xml

The PR body says jackson-databind-nullable is removed and replaced by native Optional<String>. The diff shows the JacksonConfig.java is "kept as a placeholder for future custom modules." Confirm that the pom.xml dependency was actually removed (the diff shows the frontend api.ts regen but I don't see the pom.xml diff directly). If jackson-databind-nullable was added in a previous commit on this branch and then removed, the final pom.xml should be clean. This is a verification item, not a blocker — the PR body documents the decision clearly.

No new Docker services, no new environment variables, no new secrets needed

All operational criteria pass:

  • No :latest tags introduced
  • No bind-mounted persistent data
  • No hardcoded secrets
  • No new external dependencies at runtime
  • Migration runs cleanly in Flyway's default single-transaction mode

CI impact

JourneyItemConstraintsTest is a @SpringBootTest test — it will start a Testcontainers Postgres. This is already the pattern used elsewhere in the integration test suite, so no CI time regression beyond the proportional addition. The 670-line JourneyItemServiceTest is pure Mockito — runs in milliseconds.

## 🖥️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No Docker Compose changes, no CI workflow changes, no new infrastructure. My scope is the migration, the `jackson-databind-nullable` dependency lifecycle, and any operational implications. --- ### Observations **V73 migration — operational risk note** The migration is clean and adds the right comment about PgBouncer transaction mode. One operational note: the migration uses `ALTER TABLE ... ADD CONSTRAINT` twice in a single file with no `executeInTransaction=false`. Flyway wraps each migration in a transaction by default, and `ALTER TABLE ADD CONSTRAINT ... DEFERRABLE` requires DDL to run inside a transaction on PostgreSQL — this is correct and the comment acknowledges it. Good. The PgBouncer warning in the SQL comment is important enough to track operationally. When/if we switch from PgBouncer transaction mode to statement mode, DEFERRABLE constraints silently stop working — the deferred check fires at statement end rather than transaction end. This is not a showstopper here, but I'd want a Gitea issue or an ops note in `docs/infrastructure/production-compose.md` to track this dependency. The ADR comment from Markus applies here as well. **`jackson-databind-nullable` removed from pom.xml** The PR body says `jackson-databind-nullable` is removed and replaced by native `Optional<String>`. The diff shows the `JacksonConfig.java` is "kept as a placeholder for future custom modules." Confirm that the `pom.xml` dependency was actually removed (the diff shows the frontend `api.ts` regen but I don't see the `pom.xml` diff directly). If `jackson-databind-nullable` was added in a previous commit on this branch and then removed, the final pom.xml should be clean. This is a verification item, not a blocker — the PR body documents the decision clearly. **No new Docker services, no new environment variables, no new secrets needed** All operational criteria pass: - No `:latest` tags introduced - No bind-mounted persistent data - No hardcoded secrets - No new external dependencies at runtime - Migration runs cleanly in Flyway's default single-transaction mode **CI impact** `JourneyItemConstraintsTest` is a `@SpringBootTest` test — it will start a Testcontainers Postgres. This is already the pattern used elsewhere in the integration test suite, so no CI time regression beyond the proportional addition. The 670-line `JourneyItemServiceTest` is pure Mockito — runs in milliseconds.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Reviewing from a requirements completeness and specification fidelity lens. The implementation is clearly built from a well-specified issue — the error codes, capacity cap, three-way PATCH semantics, and IDOR protection all suggest the specification was detailed. Two concerns relate to unresolved requirements ambiguities that could create friction for the follow-on reader UI PR.


Blockers

1. The "at least one of documentId or note" invariant is enforced on CREATE but not on the data model level

The append service enforces: if documentId == null && note == null → 400. But the journey_items table (V72, referenced not included in this PR's diff) apparently has this invariant as a CHECK constraint. The V73 migration adds the UNIQUE and CHECK(position > 0) constraints but does not add CHECK (document_id IS NOT NULL OR note IS NOT NULL).

If that CHECK constraint was added in V72 (the previous PR), this is fine and complete. If it was not, then the invariant is only enforced at the application layer with a race condition window (two concurrent writes could both pass the null check before the DB enforces anything). The PR description mentions "A CHECK constraint ensures at least one of document_id or note is present" — verify this is in V72, not just in the Javadoc.

2. The reorder endpoint contract is underspecified for the partial-list case

PUT /{id}/items/reorder requires the request to contain all item IDs for the journey. A partial list (wanting to move just one item) returns 400. This is a valid design choice, but it is not documented in the API or the error message. The error message is: "Requested item IDs do not match the journey's existing items" — a developer calling this endpoint would not immediately know they must send all IDs.

Recommendation: Add an @Operation / @ApiResponse OpenAPI annotation on the reorder endpoint explaining that itemIds must contain all item IDs for the journey, in the desired new order. This is a docs-only fix, not a code change — but it closes a requirements communication gap for the frontend consumer.


Observations

Capacity cap: 100 items

The 100-item cap is enforced via COUNT. The GLOSSARY now documents "max 100 items per journey." No issue — confirm this number was in the original issue specification and is not a constraint introduced by the implementation without product agreement.

GESCHICHTE_TYPE_MISMATCH error code

This code fires when a caller attempts to append to a STORY-type Geschichte. This is a correct guard. However, the reorder endpoint does not enforce the JOURNEY type guard — it only checks existence. If a STORY-type Geschichte somehow gets journey items (e.g. created programmatically or via import), reorder would succeed on it. This is unlikely but worth a follow-up issue to add the type guard to reorder as well, or document that STORY cannot have items as an invariant enforced elsewhere.

WIP PR risk

This PR is marked as implementing Tasks 6-9 of a larger issue (#751). Four tasks remain (endpoints, slice tests, frontend codegen). Merging a WIP PR with the getById return type changed to GeschichteView means the current frontend Geschichte detail page will receive a different response shape. Confirm the frontend geschichten/[id] page still works with GeschichteView before this lands in main.

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Reviewing from a requirements completeness and specification fidelity lens. The implementation is clearly built from a well-specified issue — the error codes, capacity cap, three-way PATCH semantics, and IDOR protection all suggest the specification was detailed. Two concerns relate to unresolved requirements ambiguities that could create friction for the follow-on reader UI PR. --- ### Blockers **1. The "at least one of documentId or note" invariant is enforced on CREATE but not on the data model level** The `append` service enforces: if `documentId == null && note == null → 400`. But the `journey_items` table (V72, referenced not included in this PR's diff) apparently has this invariant as a CHECK constraint. The V73 migration adds the UNIQUE and CHECK(position > 0) constraints but does not add `CHECK (document_id IS NOT NULL OR note IS NOT NULL)`. If that CHECK constraint was added in V72 (the previous PR), this is fine and complete. If it was not, then the invariant is only enforced at the application layer with a race condition window (two concurrent writes could both pass the null check before the DB enforces anything). The PR description mentions "A CHECK constraint ensures at least one of `document_id` or `note` is present" — verify this is in V72, not just in the Javadoc. **2. The `reorder` endpoint contract is underspecified for the partial-list case** `PUT /{id}/items/reorder` requires the request to contain *all* item IDs for the journey. A partial list (wanting to move just one item) returns 400. This is a valid design choice, but it is not documented in the API or the error message. The error message is: *"Requested item IDs do not match the journey's existing items"* — a developer calling this endpoint would not immediately know they must send all IDs. **Recommendation:** Add an `@Operation` / `@ApiResponse` OpenAPI annotation on the reorder endpoint explaining that `itemIds` must contain all item IDs for the journey, in the desired new order. This is a docs-only fix, not a code change — but it closes a requirements communication gap for the frontend consumer. --- ### Observations **Capacity cap: 100 items** The 100-item cap is enforced via COUNT. The GLOSSARY now documents "max 100 items per journey." No issue — confirm this number was in the original issue specification and is not a constraint introduced by the implementation without product agreement. **`GESCHICHTE_TYPE_MISMATCH` error code** This code fires when a caller attempts to `append` to a STORY-type Geschichte. This is a correct guard. However, the reorder endpoint does *not* enforce the JOURNEY type guard — it only checks existence. If a STORY-type Geschichte somehow gets journey items (e.g. created programmatically or via import), reorder would succeed on it. This is unlikely but worth a follow-up issue to add the type guard to reorder as well, or document that STORY cannot have items as an invariant enforced elsewhere. **WIP PR risk** This PR is marked as implementing Tasks 6-9 of a larger issue (#751). Four tasks remain (endpoints, slice tests, frontend codegen). Merging a WIP PR with the `getById` return type changed to `GeschichteView` means the current frontend Geschichte detail page will receive a different response shape. Confirm the frontend `geschichten/[id]` page still works with `GeschichteView` before this lands in main.
marcel added 8 commits 2026-06-08 20:55:07 +02:00
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>
Author
Owner

Phase 4 review concerns addressed — 8 commits

All items from the second-pass review have been implemented and pushed to feat/issue-751-journey-item-crud-api.


Task 1 — Circular dependency refactor (164178ec)

GeschichteService.getById() now returns the Geschichte entity with the DRAFT visibility guard intact. GeschichteController.getById() calls geschichteService.getById(), then journeyItemService.getItems(), then geschichteService.toView() to assemble the view — removing the GeschichteService → JourneyItemService injection. GeschichteServiceTest updated: getById tests now assert on Geschichte return values; view-assembly tests moved to toView_* methods. GeschichteControllerTest updated to stub both service calls. GeschichteServiceIntegrationTest updated to call both service methods and assemble the view inline.

Task 2 — N+1 SELECT eliminated (ad90ae75)

Added findByGeschichteIdWithDocument() to JourneyItemRepository with a LEFT JOIN FETCH ji.document. getItems() now uses this query. toView() reads item.getDocument() directly from the already-fetched association instead of issuing a separate documentService.getSummaryById() call per item. Unit tests updated to remove now-unused documentService stubs (Mockito strict stubbing would have flagged them).

Task 3 — C4 L3 diagram updated (70da532f)

Added GeschichteQueryService component to l3-backend-3g-supporting.puml. Removed the deleted Rel(geschSvc, journeyItemSvc, "Delegates getItems()") arrow. Added Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type") and Rel(geschQuerySvc, db, "Reads geschichten", "JDBC").

Task 4 — Integration tests for append and reorder (f9ae6a91)

Added two service-level tests to JourneyItemIntegrationTest (real PostgreSQL via Testcontainers): append_persists_item_at_position_10 and reorder_swaps_positions_atomically. Uses the SecurityContextHolder + AppUserRepository authentication pattern from GeschichteServiceIntegrationTest.

Task 5 — Null-note serialization test fixed (c31f82a6)

Replaced .andExpect(jsonPath("$.note").doesNotExist()) with .andExpect(jsonPath("$.note").value(nullValue())) in updateItemNote_json_null_note_is_deserialized_as_empty_Optional. The key is present in the JSON with a null value — doesNotExist() was testing the wrong thing.

Task 6 — getSummaryById Javadoc (73004ce4)

Updated the Javadoc on DocumentService.getSummaryById() to explicitly state it skips scope checks and tag-colour resolution, and that this is safe under the current single-tenant model. The method is still called by JourneyItemService.append() for document existence validation.

Task 7 — OpenAPI reorder contract (9db3b41f)

Added @Operation(summary, description) to GeschichteController.reorderItems() documenting that itemIds must contain all item IDs for the journey. A partial list returns 400 Bad Request.

Task 8 — PersonNameFormatter extracted (11082774)

Created PersonNameFormatter.join(firstName, lastName) in the geschichte package. Replaced the inline string-concatenation in GeschichteService.toView() and the private join() method in JourneyItemService with calls to this helper. All null-safety and trimming logic is now in one place.


./mvnw clean package -DskipTests — BUILD SUCCESS on all 8 commits. No new frontend type errors introduced.

## Phase 4 review concerns addressed — 8 commits All items from the second-pass review have been implemented and pushed to `feat/issue-751-journey-item-crud-api`. --- ### Task 1 — Circular dependency refactor (164178ec) `GeschichteService.getById()` now returns the `Geschichte` entity with the DRAFT visibility guard intact. `GeschichteController.getById()` calls `geschichteService.getById()`, then `journeyItemService.getItems()`, then `geschichteService.toView()` to assemble the view — removing the `GeschichteService → JourneyItemService` injection. `GeschichteServiceTest` updated: `getById` tests now assert on `Geschichte` return values; view-assembly tests moved to `toView_*` methods. `GeschichteControllerTest` updated to stub both service calls. `GeschichteServiceIntegrationTest` updated to call both service methods and assemble the view inline. ### Task 2 — N+1 SELECT eliminated (ad90ae75) Added `findByGeschichteIdWithDocument()` to `JourneyItemRepository` with a `LEFT JOIN FETCH ji.document`. `getItems()` now uses this query. `toView()` reads `item.getDocument()` directly from the already-fetched association instead of issuing a separate `documentService.getSummaryById()` call per item. Unit tests updated to remove now-unused `documentService` stubs (Mockito strict stubbing would have flagged them). ### Task 3 — C4 L3 diagram updated (70da532f) Added `GeschichteQueryService` component to `l3-backend-3g-supporting.puml`. Removed the deleted `Rel(geschSvc, journeyItemSvc, "Delegates getItems()")` arrow. Added `Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")` and `Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")`. ### Task 4 — Integration tests for append and reorder (f9ae6a91) Added two service-level tests to `JourneyItemIntegrationTest` (real PostgreSQL via Testcontainers): `append_persists_item_at_position_10` and `reorder_swaps_positions_atomically`. Uses the `SecurityContextHolder` + `AppUserRepository` authentication pattern from `GeschichteServiceIntegrationTest`. ### Task 5 — Null-note serialization test fixed (c31f82a6) Replaced `.andExpect(jsonPath("$.note").doesNotExist())` with `.andExpect(jsonPath("$.note").value(nullValue()))` in `updateItemNote_json_null_note_is_deserialized_as_empty_Optional`. The key is present in the JSON with a null value — `doesNotExist()` was testing the wrong thing. ### Task 6 — getSummaryById Javadoc (73004ce4) Updated the Javadoc on `DocumentService.getSummaryById()` to explicitly state it skips scope checks and tag-colour resolution, and that this is safe under the current single-tenant model. The method is still called by `JourneyItemService.append()` for document existence validation. ### Task 7 — OpenAPI reorder contract (9db3b41f) Added `@Operation(summary, description)` to `GeschichteController.reorderItems()` documenting that `itemIds` must contain **all** item IDs for the journey. A partial list returns 400 Bad Request. ### Task 8 — PersonNameFormatter extracted (11082774) Created `PersonNameFormatter.join(firstName, lastName)` in the `geschichte` package. Replaced the inline string-concatenation in `GeschichteService.toView()` and the private `join()` method in `JourneyItemService` with calls to this helper. All null-safety and trimming logic is now in one place. --- `./mvnw clean package -DskipTests` — BUILD SUCCESS on all 8 commits. No new frontend type errors introduced.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid work overall — the service layer is clean, the guard-clause pattern is applied correctly, and the test coverage is unusually thorough. A few things warrant attention before merge.


Blockers

1. JourneyItemUpdateDTO uses raw Optional<String> as a field — Lombok + Jackson anti-pattern

JourneyItemUpdateDTO.java:

@Data
public class JourneyItemUpdateDTO {
    private Optional<String> note = null;
}

@Data generates setNote(Optional<String> note). Lombok and raw Optional as field types don't play well together — Optional is not designed to be a field or method parameter per its own Javadoc, and some serializers (and future Jackson upgrades) will behave unexpectedly. The ADR documents why JsonNullable was dropped, which is fair. But the service reads dto.getNote() and null-checks it manually — this relies on an undocumented convention (null = absent). Consider using a dedicated sealed type or a boolean notePresent flag + nullable String noteValue to make the three states explicit and Lombok-friendly.

This is a blocker because the convention is not self-documenting to future developers and breaks the "names reveal intent" rule. A JourneyNoteUpdate value object or even a simple record would be clearer.

2. GeschichteController.getById now runs two queries outside a shared transaction

// GeschichteController.java
Geschichte g = geschichteService.getById(id);          // query 1
List<JourneyItemView> items = journeyItemService.getItems(g.getId());  // query 2
return geschichteService.toView(g, items);

There is no @Transactional wrapping these two calls at the controller level. Between query 1 and query 2 another request could delete the Geschichte or alter its items, producing an inconsistent snapshot. This should either be wrapped in a service method that performs both lookups atomically, or the controller should delegate to a single geschichteService.getView(id) method that owns both queries within one transaction. Controllers are not the right place to orchestrate multi-query reads.


Suggestions

3. GeschichteQueryService exposes findById returning Optional — callers should not unwrap

GeschichteQueryService.findById is called by JourneyItemService.append and immediately .orElseThrow()'d. That's fine. But GeschichteQueryService could instead expose getById with the throw included, matching the pattern used across other services (DocumentService.getById, PersonService.getById). The current findById pushes error-handling responsibility to callers.

4. reorder method in JourneyItemService — two repository reads in a @Transactional method

Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);  // read 1
// ...
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);  // read 2

Two queries when one suffices. findByGeschichteIdOrderByPosition already fetches all items — their IDs can be extracted from the result, making findIdsByGeschichteId redundant. This eliminates one round-trip and simplifies the query inventory.

5. savedItemWithDoc helper in JourneyItemServiceTest has a single-statement body with an unnecessary variable

private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
    JourneyItem item = JourneyItem.builder()...build();
    return item;
}

One line: return JourneyItem.builder()...build(); — the variable adds no value.

6. toSummary and toView are package-private — intentional?

Both methods in JourneyItemService have no access modifier. If they are test-visible by design, a @VisibleForTesting annotation (or a brief comment) would communicate the intent. Otherwise, consider making them private and testing through the public API.

7. GeschichteService.toView is package-private called from GeschichteController

toView is called from GeschichteController, which is in the same package, so this compiles. But it creates invisible coupling — any refactor that moves the controller or the service to sub-packages will silently break. Either make toView public or move the assembly into the controller's own private method.


What's done well

  • Guard clauses are consistently applied; happy path is at the lowest indentation level everywhere.
  • PersonNameFormatter is a clean extraction — DRY applied correctly.
  • JourneyItemConstraintsTest uses raw JDBC and is not @Transactional at class level — this is exactly right for testing DEFERRABLE constraints.
  • The reorder_of_grandfathered_over_cap_journey_succeeds test is a great edge case to have.
  • ADR-035 is well-reasoned and appropriately scoped.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid work overall — the service layer is clean, the guard-clause pattern is applied correctly, and the test coverage is unusually thorough. A few things warrant attention before merge. --- ### Blockers **1. `JourneyItemUpdateDTO` uses raw `Optional<String>` as a field — Lombok + Jackson anti-pattern** `JourneyItemUpdateDTO.java`: ```java @Data public class JourneyItemUpdateDTO { private Optional<String> note = null; } ``` `@Data` generates `setNote(Optional<String> note)`. Lombok and raw `Optional` as field types don't play well together — `Optional` is not designed to be a field or method parameter per its own Javadoc, and some serializers (and future Jackson upgrades) will behave unexpectedly. The ADR documents why `JsonNullable` was dropped, which is fair. But the service reads `dto.getNote()` and null-checks it manually — this relies on an undocumented convention (null = absent). Consider using a dedicated sealed type or a boolean `notePresent` flag + nullable `String noteValue` to make the three states explicit and Lombok-friendly. This is a blocker because the convention is not self-documenting to future developers and breaks the "names reveal intent" rule. A `JourneyNoteUpdate` value object or even a simple `record` would be clearer. **2. `GeschichteController.getById` now runs two queries outside a shared transaction** ```java // GeschichteController.java Geschichte g = geschichteService.getById(id); // query 1 List<JourneyItemView> items = journeyItemService.getItems(g.getId()); // query 2 return geschichteService.toView(g, items); ``` There is no `@Transactional` wrapping these two calls at the controller level. Between query 1 and query 2 another request could delete the Geschichte or alter its items, producing an inconsistent snapshot. This should either be wrapped in a service method that performs both lookups atomically, or the controller should delegate to a single `geschichteService.getView(id)` method that owns both queries within one transaction. Controllers are not the right place to orchestrate multi-query reads. --- ### Suggestions **3. `GeschichteQueryService` exposes `findById` returning `Optional` — callers should not unwrap** `GeschichteQueryService.findById` is called by `JourneyItemService.append` and immediately `.orElseThrow()`'d. That's fine. But `GeschichteQueryService` could instead expose `getById` with the throw included, matching the pattern used across other services (`DocumentService.getById`, `PersonService.getById`). The current `findById` pushes error-handling responsibility to callers. **4. `reorder` method in `JourneyItemService` — two repository reads in a `@Transactional` method** ```java Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); // read 1 // ... List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId); // read 2 ``` Two queries when one suffices. `findByGeschichteIdOrderByPosition` already fetches all items — their IDs can be extracted from the result, making `findIdsByGeschichteId` redundant. This eliminates one round-trip and simplifies the query inventory. **5. `savedItemWithDoc` helper in `JourneyItemServiceTest` has a single-statement body with an unnecessary variable** ```java private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) { JourneyItem item = JourneyItem.builder()...build(); return item; } ``` One line: `return JourneyItem.builder()...build();` — the variable adds no value. **6. `toSummary` and `toView` are package-private — intentional?** Both methods in `JourneyItemService` have no access modifier. If they are test-visible by design, a `@VisibleForTesting` annotation (or a brief comment) would communicate the intent. Otherwise, consider making them `private` and testing through the public API. **7. `GeschichteService.toView` is package-private called from `GeschichteController`** `toView` is called from `GeschichteController`, which is in the same package, so this compiles. But it creates invisible coupling — any refactor that moves the controller or the service to sub-packages will silently break. Either make `toView` public or move the assembly into the controller's own private method. --- ### What's done well - Guard clauses are consistently applied; happy path is at the lowest indentation level everywhere. - `PersonNameFormatter` is a clean extraction — DRY applied correctly. - `JourneyItemConstraintsTest` uses raw JDBC and is not `@Transactional` at class level — this is exactly right for testing DEFERRABLE constraints. - The `reorder_of_grandfathered_over_cap_journey_succeeds` test is a great edge case to have. - ADR-035 is well-reasoned and appropriately scoped.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

The structural choices here are largely sound. The GeschichteQueryService anti-cycle facade is exactly the right pattern when a sub-domain needs to reference a parent domain without circular injection. The DB diagram and C4 diagram updates are present. I have two architectural concerns that need resolution.


Blockers

1. Two-query controller orchestration violates the layering rule

// GeschichteController.getById
Geschichte g = geschichteService.getById(id);
List<JourneyItemView> items = journeyItemService.getItems(g.getId());
return geschichteService.toView(g, items);

The layering rule is explicit: controllers never orchestrate multiple service calls for a single read. Business logic — including "load the story and its items together" — belongs in the service layer where it can be transactional, tested with @Mock, and reused. The controller here is doing view assembly across two services.

The fix: move this into GeschichteService.getView(UUID id), which calls both getById and journeyItemService.getItems and assembles the GeschichteView in one transactional method. The controller reduces to a single delegation call.

2. DocumentSummary is placed in the geschichte package — this is a cross-domain type

DocumentSummary.java lives at org.raddatz.familienarchiv.geschichte. It represents a projection of Document data, but it's owned by the geschichte package. This is acceptable if the projection is purely for journey-item rendering and never reused by other domains. However: JourneyItemService builds it from a Document entity (via DocumentService.getSummaryById). The document domain has no knowledge of this projection, but the geschichte domain is now projecting document fields manually.

This is a smell, not a hard violation — the single-tenant context means there's no security boundary being crossed. But if Document gains or renames fields, JourneyItemService.toSummary() will silently lag. Consider whether DocumentService should own a toSummary(Document) method that can be updated in one place when the document schema evolves.


Suggestions

3. GeschichteQueryService is thin but correct

Its existence is justified — it prevents JourneyItemService from injecting GeschichteRepository directly, which would violate the module boundary. However, it currently only exposes two methods. Consider whether this will stay minimal or grow; if it grows into a full read service it should be renamed GeschichteReadService or similar to signal intent.

4. C4 diagram is updated but relationship direction notation

In l3-backend-3g-supporting.puml:

Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")

JourneyItemService does not talk to the database directly — it talks to JourneyItemRepository. The technology label on Rel should be "JPA" not "JDBC" to be consistent with how other service→db relationships are documented. Minor, but diagram drift matters over time.

5. PgBouncer transaction-mode note in the migration is good architecture documentation

The comment in V73__add_journey_items_position_constraints.sql about PgBouncer transaction mode and DEFERRABLE constraints is exactly the kind of operational context that should be in a migration. Well done — this is the kind of note that prevents a 2am incident when someone changes pooling configuration.

6. The 100-item cap is application-layer only — no DB-level enforcement

The cap is enforced by countByGeschichteId >= MAX_ITEMS in the service. There is no corresponding database constraint (e.g. a trigger or a partial index). Under the single-tenant model with low concurrency, this is acceptable. But the race condition window (two concurrent appends both reading count=99 and both proceeding) is worth acknowledging explicitly, especially since the DEFERRABLE unique constraint will catch position collisions but NOT the cap. The PR body mentions this as intentional — it would be worth a one-line comment in the service method for future readers.


What's done well

  • The GeschichteQueryService facade is the correct pattern for this dependency direction.
  • ADR-035 correctly documents the Jackson 3.x constraint and the chosen workaround.
  • CLAUDE.md, GLOSSARY.md, and the C4/DB diagrams are all updated in the same PR — this is rare and appreciated.
  • The DEFERRABLE constraint is the right tool for atomic position swaps; the migration comment explains the trade-offs clearly.
## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** The structural choices here are largely sound. The `GeschichteQueryService` anti-cycle facade is exactly the right pattern when a sub-domain needs to reference a parent domain without circular injection. The DB diagram and C4 diagram updates are present. I have two architectural concerns that need resolution. --- ### Blockers **1. Two-query controller orchestration violates the layering rule** ```java // GeschichteController.getById Geschichte g = geschichteService.getById(id); List<JourneyItemView> items = journeyItemService.getItems(g.getId()); return geschichteService.toView(g, items); ``` The layering rule is explicit: controllers never orchestrate multiple service calls for a single read. Business logic — including "load the story and its items together" — belongs in the service layer where it can be transactional, tested with `@Mock`, and reused. The controller here is doing view assembly across two services. The fix: move this into `GeschichteService.getView(UUID id)`, which calls both `getById` and `journeyItemService.getItems` and assembles the `GeschichteView` in one transactional method. The controller reduces to a single delegation call. **2. `DocumentSummary` is placed in the `geschichte` package — this is a cross-domain type** `DocumentSummary.java` lives at `org.raddatz.familienarchiv.geschichte`. It represents a projection of `Document` data, but it's owned by the `geschichte` package. This is acceptable if the projection is purely for journey-item rendering and never reused by other domains. However: `JourneyItemService` builds it from a `Document` entity (via `DocumentService.getSummaryById`). The document domain has no knowledge of this projection, but the geschichte domain is now projecting document fields manually. This is a smell, not a hard violation — the single-tenant context means there's no security boundary being crossed. But if `Document` gains or renames fields, `JourneyItemService.toSummary()` will silently lag. Consider whether `DocumentService` should own a `toSummary(Document)` method that can be updated in one place when the document schema evolves. --- ### Suggestions **3. `GeschichteQueryService` is thin but correct** Its existence is justified — it prevents `JourneyItemService` from injecting `GeschichteRepository` directly, which would violate the module boundary. However, it currently only exposes two methods. Consider whether this will stay minimal or grow; if it grows into a full read service it should be renamed `GeschichteReadService` or similar to signal intent. **4. C4 diagram is updated but relationship direction notation** In `l3-backend-3g-supporting.puml`: ``` Rel(geschQuerySvc, db, "Reads geschichten", "JDBC") Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC") ``` `JourneyItemService` does not talk to the database directly — it talks to `JourneyItemRepository`. The technology label on `Rel` should be `"JPA"` not `"JDBC"` to be consistent with how other service→db relationships are documented. Minor, but diagram drift matters over time. **5. PgBouncer transaction-mode note in the migration is good architecture documentation** The comment in `V73__add_journey_items_position_constraints.sql` about PgBouncer transaction mode and DEFERRABLE constraints is exactly the kind of operational context that should be in a migration. Well done — this is the kind of note that prevents a 2am incident when someone changes pooling configuration. **6. The 100-item cap is application-layer only — no DB-level enforcement** The cap is enforced by `countByGeschichteId >= MAX_ITEMS` in the service. There is no corresponding database constraint (e.g. a trigger or a partial index). Under the single-tenant model with low concurrency, this is acceptable. But the race condition window (two concurrent appends both reading count=99 and both proceeding) is worth acknowledging explicitly, especially since the DEFERRABLE unique constraint will catch position collisions but NOT the cap. The PR body mentions this as intentional — it would be worth a one-line comment in the service method for future readers. --- ### What's done well - The `GeschichteQueryService` facade is the correct pattern for this dependency direction. - ADR-035 correctly documents the Jackson 3.x constraint and the chosen workaround. - CLAUDE.md, GLOSSARY.md, and the C4/DB diagrams are all updated in the same PR — this is rare and appreciated. - The DEFERRABLE constraint is the right tool for atomic position swaps; the migration comment explains the trade-offs clearly.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Good security awareness throughout — IDOR protection via findByIdAndGeschichteId, email suppression in AuthorView, and structured error codes that don't leak schema details. I have one confirmed blocker and two security smells to flag.


Blockers

1. DocumentService.getSummaryById skips all scope checks — comment is accurate but the method is public

/**
 * Lightweight summary lookup for internal use (e.g. journey item append validation).
 * Intentionally skips scope checks and tag-colour resolution — safe only
 * under the current single-tenant model where all authenticated users share
 * the same document scope. Called within a caller-provided transaction.
 */
public Document getSummaryById(UUID id) {
    return documentRepository.findById(id)
            .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}

The comment is honest about the single-tenant assumption, but this method is public. Any future code can call it without the caller realizing scope checks are bypassed. In the current codebase this is exploitable only if a second tenant is added — but the public surface means the assumption is not enforced by the compiler.

Fix: Either make it package-private and @VisibleForTesting, or add a Javadoc @apiNote that marks it explicitly as INTERNAL — skip-security — single-tenant only. Strongly prefer the package-private approach or moving the scope check into this method so it is always safe to call regardless of context. The current design is a latent privilege escalation if tenancy is introduced.

Additionally: this method name getSummaryById sounds like a harmless read, but it actually bypasses authorization. It should be named to signal this: getUnscopedById, getRawById, or findByIdForInternalUse. CWE-284 (Improper Access Control) — low severity now, elevated severity on multi-tenant expansion.


Security Smells (not blockers for single-tenant, but worth noting)

2. currentUser() reads from SecurityContextHolder directly — not testable and could silently fail

private AppUser currentUser() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !auth.isAuthenticated()) {
        throw DomainException.unauthorized("Authentication required");
    }
    return userService.findByEmail(auth.getName());
}

This pattern is used correctly in the existing codebase, so it's consistent. The check auth == null || !auth.isAuthenticated() is correct. One concern: if auth.isAuthenticated() is true but auth.getName() returns an anonymous user string (Spring Security's anonymousUser), userService.findByEmail("anonymousUser") will throw an unstructured exception rather than DomainException.unauthorized. This is an edge case in practice (anonymous tokens are only set when @RequirePermission is not present on the endpoint), but the controller endpoints do all have @RequirePermission(BLOG_WRITE), so this is covered by the AOP check before the service is called.

No action required — documenting for awareness.

3. GlobalExceptionHandler constraint-name-to-ErrorCode mapping: string literal is correct

if ("uq_journey_items_geschichte_position".equals(constraint)) {
    return ResponseEntity.status(409).body(new ErrorResponse(...));
}

The constraint name is hardcoded as a string in the exception handler. If the constraint is renamed in a future migration, this silently reverts to the generic 400 response without any test catching it. There is currently no test that verifies the constraint-name extraction path end-to-end with a real DB (the controller test mocks the service, which bypasses the exception handler's constraint logic entirely).

Consider adding a comment referencing the migration: // matches constraint name from V73. No code change required — just documentation.

4. IDOR protection is present and correct

findByIdAndGeschichteId on every item lookup is the right pattern. When itemId exists but belongs to a different journey, the 404 response is indistinguishable from "item not found" — this is correct security behavior (no oracle for enumeration).

5. AuthorView email suppression is tested

The test toView_author_email_is_not_in_author_view explicitly verifies the email is absent from the response. This is exactly the regression test that should exist. Good discipline.


What's done well

  • AuthorView leaks no email, groups, or permissions — only id and displayName.
  • IDOR protection is applied consistently (findByIdAndGeschichteId) across all item-level operations.
  • Error codes do not embed SQL or stack traces (the GlobalExceptionHandler comment about CWE-209 is present and correct).
  • @RequirePermission(Permission.BLOG_WRITE) is on every write endpoint — no endpoint is accidentally unprotected.
  • The 401 Unauthenticated test for appendItem is present (most PRs only test 403).
## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** Good security awareness throughout — IDOR protection via `findByIdAndGeschichteId`, email suppression in `AuthorView`, and structured error codes that don't leak schema details. I have one confirmed blocker and two security smells to flag. --- ### Blockers **1. `DocumentService.getSummaryById` skips all scope checks — comment is accurate but the method is public** ```java /** * Lightweight summary lookup for internal use (e.g. journey item append validation). * Intentionally skips scope checks and tag-colour resolution — safe only * under the current single-tenant model where all authenticated users share * the same document scope. Called within a caller-provided transaction. */ public Document getSummaryById(UUID id) { return documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); } ``` The comment is honest about the single-tenant assumption, but this method is `public`. Any future code can call it without the caller realizing scope checks are bypassed. In the current codebase this is exploitable only if a second tenant is added — but the public surface means the assumption is not enforced by the compiler. **Fix:** Either make it package-private and `@VisibleForTesting`, or add a Javadoc `@apiNote` that marks it explicitly as `INTERNAL — skip-security — single-tenant only`. Strongly prefer the package-private approach or moving the scope check into this method so it is always safe to call regardless of context. The current design is a latent privilege escalation if tenancy is introduced. Additionally: this method name `getSummaryById` sounds like a harmless read, but it actually bypasses authorization. It should be named to signal this: `getUnscopedById`, `getRawById`, or `findByIdForInternalUse`. CWE-284 (Improper Access Control) — low severity now, elevated severity on multi-tenant expansion. --- ### Security Smells (not blockers for single-tenant, but worth noting) **2. `currentUser()` reads from `SecurityContextHolder` directly — not testable and could silently fail** ```java private AppUser currentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) { throw DomainException.unauthorized("Authentication required"); } return userService.findByEmail(auth.getName()); } ``` This pattern is used correctly in the existing codebase, so it's consistent. The check `auth == null || !auth.isAuthenticated()` is correct. One concern: if `auth.isAuthenticated()` is `true` but `auth.getName()` returns an anonymous user string (Spring Security's `anonymousUser`), `userService.findByEmail("anonymousUser")` will throw an unstructured exception rather than `DomainException.unauthorized`. This is an edge case in practice (anonymous tokens are only set when `@RequirePermission` is not present on the endpoint), but the controller endpoints do all have `@RequirePermission(BLOG_WRITE)`, so this is covered by the AOP check before the service is called. No action required — documenting for awareness. **3. `GlobalExceptionHandler` constraint-name-to-ErrorCode mapping: string literal is correct** ```java if ("uq_journey_items_geschichte_position".equals(constraint)) { return ResponseEntity.status(409).body(new ErrorResponse(...)); } ``` The constraint name is hardcoded as a string in the exception handler. If the constraint is renamed in a future migration, this silently reverts to the generic 400 response without any test catching it. There is currently no test that verifies the constraint-name extraction path end-to-end with a real DB (the controller test mocks the service, which bypasses the exception handler's constraint logic entirely). Consider adding a comment referencing the migration: `// matches constraint name from V73`. No code change required — just documentation. **4. IDOR protection is present and correct** `findByIdAndGeschichteId` on every item lookup is the right pattern. When `itemId` exists but belongs to a different journey, the 404 response is indistinguishable from "item not found" — this is correct security behavior (no oracle for enumeration). **5. `AuthorView` email suppression is tested** The test `toView_author_email_is_not_in_author_view` explicitly verifies the email is absent from the response. This is exactly the regression test that should exist. Good discipline. --- ### What's done well - `AuthorView` leaks no email, groups, or permissions — only `id` and `displayName`. - IDOR protection is applied consistently (`findByIdAndGeschichteId`) across all item-level operations. - Error codes do not embed SQL or stack traces (the GlobalExceptionHandler comment about CWE-209 is present and correct). - `@RequirePermission(Permission.BLOG_WRITE)` is on every write endpoint — no endpoint is accidentally unprotected. - The `401 Unauthenticated` test for `appendItem` is present (most PRs only test 403).
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

The test suite for this PR is one of the most thorough I've seen in this codebase. The three-layer coverage (unit / integration / constraint), the explicit audit verification, and the edge-case breadth are all excellent. I have no blockers — a few targeted gaps to fill in a follow-up.


What's Excellent

Test pyramid is properly structured:

  • JourneyItemServiceTest — 668 lines of pure Mockito unit tests; no Spring context. Runs in milliseconds.
  • GeschichteControllerTest@WebMvcTest slice, tests 401/403/404/409 per endpoint. Correct layer.
  • JourneyItemIntegrationTest@SpringBootTest + real Postgres via PostgresContainerConfig. Tests persistence, ordering, cascade delete.
  • JourneyItemConstraintsTest — deliberately not @Transactional at class level (correct — class-level @Transactional breaks DataIntegrityViolationException testing). This is exactly the right setup.

Error path coverage is thorough: not-found, type-mismatch, capacity, null+no-doc, IDOR — all tested.

Audit verification uses verify(auditService) with exact AuditKind args — not just "was it called."


Gaps to Address in Follow-up

1. No test covering the GlobalExceptionHandler's new constraint-name-to-ErrorCode branch

The DEFERRABLE constraint fires at commit time, producing a DataIntegrityViolationException. The handler in GlobalExceptionHandler maps uq_journey_items_geschichte_positionJOURNEY_ITEM_POSITION_CONFLICT. The controller test mocks the service and bypasses this path entirely. The integration test for reorder (reorder_swaps_positions_atomically) does not provoke a concurrent conflict.

There is no test that verifies: when two concurrent appends produce a duplicate-position DataIntegrityViolationException at the DB level, the response is 409 JOURNEY_ITEM_POSITION_CONFLICT rather than the generic 400. This is the path the ADR and migration comments focus on — it deserves a test. (Track as a follow-up; acceptable to defer if the integration environment makes concurrent test setup hard.)

2. JourneyItemIntegrationTest.append_persists_item_at_position_10 — missing @Transactional rollback

The integration test calls journeyItemService.append(...) which commits inside its own @Transactional. The @BeforeEach seed uses documentRepository.save and geschichteRepository.save. The test class has @Transactional at class level — but the service's own @Transactional commits within a nested transaction, meaning the test's rollback may not clean up items appended by the service. The @AfterEach clearSecurity() is present but there's no explicit cleanup of journey_items.

Looking at the constraint test, it handles this via jdbcTemplate.execute("DELETE FROM journey_items") in @BeforeEach. The integration test relies on the class-level @Transactional. This is worth verifying — if tests are order-dependent, it's a flakiness risk.

3. No test for getItems (the findByGeschichteIdWithDocument path) in isolation

JourneyItemService.getItems uses findByGeschichteIdWithDocument (the JOIN FETCH query). The integration test calls it indirectly via journeyItemService.getItems. There's no explicit test that verifies the JOIN FETCH eliminates N+1 behavior or that note-only items (no document) are correctly included via LEFT JOIN. This is not a blocker — but N+1 regressions are silent and painful.

4. GeschichteQueryServiceTest is present but thin

Only two tests (existsById true/false). findById returning Optional.empty() is not tested. Fine for a thin service — noting it for completeness.


Quality Gates Check

  • Branch coverage: the service test file is 668 lines covering happy path, all error branches, all three-way note states, and audit verification. Coverage should be well above 80% for new code.
  • No @Disabled tests.
  • No Thread.sleep calls.
  • Test names read as sentences (append_to_empty_journey_starts_at_10, reorder_swaps_positions_atomically) — exemplary.

Overall: this is the test suite I want to see for every feature PR. The identified gaps are all follow-up items, not merge blockers.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** The test suite for this PR is one of the most thorough I've seen in this codebase. The three-layer coverage (unit / integration / constraint), the explicit audit verification, and the edge-case breadth are all excellent. I have no blockers — a few targeted gaps to fill in a follow-up. --- ### What's Excellent **Test pyramid is properly structured:** - `JourneyItemServiceTest` — 668 lines of pure Mockito unit tests; no Spring context. Runs in milliseconds. - `GeschichteControllerTest` — `@WebMvcTest` slice, tests 401/403/404/409 per endpoint. Correct layer. - `JourneyItemIntegrationTest` — `@SpringBootTest` + real Postgres via `PostgresContainerConfig`. Tests persistence, ordering, cascade delete. - `JourneyItemConstraintsTest` — deliberately not `@Transactional` at class level (correct — class-level `@Transactional` breaks `DataIntegrityViolationException` testing). This is exactly the right setup. **Error path coverage is thorough:** not-found, type-mismatch, capacity, null+no-doc, IDOR — all tested. **Audit verification uses `verify(auditService)` with exact `AuditKind` args** — not just "was it called." --- ### Gaps to Address in Follow-up **1. No test covering the `GlobalExceptionHandler`'s new constraint-name-to-ErrorCode branch** The DEFERRABLE constraint fires at commit time, producing a `DataIntegrityViolationException`. The handler in `GlobalExceptionHandler` maps `uq_journey_items_geschichte_position` → `JOURNEY_ITEM_POSITION_CONFLICT`. The controller test mocks the service and bypasses this path entirely. The integration test for reorder (`reorder_swaps_positions_atomically`) does not provoke a concurrent conflict. There is no test that verifies: when two concurrent appends produce a duplicate-position `DataIntegrityViolationException` at the DB level, the response is `409 JOURNEY_ITEM_POSITION_CONFLICT` rather than the generic 400. This is the path the ADR and migration comments focus on — it deserves a test. (Track as a follow-up; acceptable to defer if the integration environment makes concurrent test setup hard.) **2. `JourneyItemIntegrationTest.append_persists_item_at_position_10` — missing `@Transactional` rollback** The integration test calls `journeyItemService.append(...)` which commits inside its own `@Transactional`. The `@BeforeEach` seed uses `documentRepository.save` and `geschichteRepository.save`. The test class has `@Transactional` at class level — but the service's own `@Transactional` commits within a nested transaction, meaning the test's rollback may not clean up items appended by the service. The `@AfterEach clearSecurity()` is present but there's no explicit cleanup of `journey_items`. Looking at the constraint test, it handles this via `jdbcTemplate.execute("DELETE FROM journey_items")` in `@BeforeEach`. The integration test relies on the class-level `@Transactional`. This is worth verifying — if tests are order-dependent, it's a flakiness risk. **3. No test for `getItems` (the `findByGeschichteIdWithDocument` path) in isolation** `JourneyItemService.getItems` uses `findByGeschichteIdWithDocument` (the JOIN FETCH query). The integration test calls it indirectly via `journeyItemService.getItems`. There's no explicit test that verifies the JOIN FETCH eliminates N+1 behavior or that note-only items (no document) are correctly included via LEFT JOIN. This is not a blocker — but N+1 regressions are silent and painful. **4. `GeschichteQueryServiceTest` is present but thin** Only two tests (existsById true/false). `findById` returning `Optional.empty()` is not tested. Fine for a thin service — noting it for completeness. --- ### Quality Gates Check - Branch coverage: the service test file is 668 lines covering happy path, all error branches, all three-way note states, and audit verification. Coverage should be well above 80% for new code. - No `@Disabled` tests. - No `Thread.sleep` calls. - Test names read as sentences (`append_to_empty_journey_starts_at_10`, `reorder_swaps_positions_atomically`) — exemplary. Overall: this is the test suite I want to see for every feature PR. The identified gaps are all follow-up items, not merge blockers.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. No new Docker services, no new environment variables, no CI workflow changes. I'm reviewing the migration, the generated API spec diff, and the dependency changes.


Observations

1. V73 migration: single-transaction guarantee is documented — good

-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
-- Do NOT add executeInTransaction=false or any callback that splits this migration.

This comment is exactly what I want to see in a migration that adds a DEFERRABLE constraint. Flyway wraps each migration in a transaction by default — the comment prevents someone from accidentally breaking this. No action needed.

2. pom.xml changes: jackson-databind-nullable removed — correct

ADR-035 documents the removal. The dependency is gone, and JacksonConfig.java is retained as a placeholder. The change reduces the dependency surface, which is good from a supply-chain perspective. No new dependencies were added.

3. Generated api.ts diff: large churn unrelated to this PR

The frontend/src/lib/generated/api.ts diff contains substantial changes beyond the new JourneyItem types — backfillTitles operation removed, search_1/search_2 renamed, GeschichteSummary removed from the schema, Geschichte schema changed (type and items fields dropped), DocumentListItem/DocumentSearchResult moved. This looks like the generated file was regenerated from the backend's current OpenAPI spec which includes accumulated changes from other merged PRs.

This is not a problem operationally — npm run generate:api is deterministic and should be run after every backend change. But it makes this diff harder to review in isolation. No action required, just noting the context.

4. No migration for a new table or index — only ALTER TABLE

V73 adds constraints to the existing journey_items table. No new tables, no new sequences. The migration is simple, non-destructive, and reversible (constraints can be dropped). Risk to production deployment: minimal.

5. No new environment variables required

The 100-item cap (MAX_ITEMS = 100) is a compile-time constant in JourneyItemService, not a configurable environment variable. For a family archive with expected journey sizes well under 100, this is appropriate. If this ever needs tuning, it would require a code change — fine for now.


What's Done Well

  • No :latest image tags (no Docker changes).
  • No hardcoded secrets.
  • The migration comment about PgBouncer transaction-mode is the kind of operational documentation that saves time during future configuration changes.
  • JacksonConfig.java placeholder is clean — no dead code, just an extension point comment.
## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. No new Docker services, no new environment variables, no CI workflow changes. I'm reviewing the migration, the generated API spec diff, and the dependency changes. --- ### Observations **1. V73 migration: single-transaction guarantee is documented — good** ```sql -- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this. -- Do NOT add executeInTransaction=false or any callback that splits this migration. ``` This comment is exactly what I want to see in a migration that adds a DEFERRABLE constraint. Flyway wraps each migration in a transaction by default — the comment prevents someone from accidentally breaking this. No action needed. **2. `pom.xml` changes: `jackson-databind-nullable` removed — correct** ADR-035 documents the removal. The dependency is gone, and `JacksonConfig.java` is retained as a placeholder. The change reduces the dependency surface, which is good from a supply-chain perspective. No new dependencies were added. **3. Generated `api.ts` diff: large churn unrelated to this PR** The `frontend/src/lib/generated/api.ts` diff contains substantial changes beyond the new JourneyItem types — `backfillTitles` operation removed, `search_1`/`search_2` renamed, `GeschichteSummary` removed from the schema, `Geschichte` schema changed (type and items fields dropped), `DocumentListItem`/`DocumentSearchResult` moved. This looks like the generated file was regenerated from the backend's current OpenAPI spec which includes accumulated changes from other merged PRs. This is not a problem operationally — `npm run generate:api` is deterministic and should be run after every backend change. But it makes this diff harder to review in isolation. No action required, just noting the context. **4. No migration for a new table or index — only ALTER TABLE** V73 adds constraints to the existing `journey_items` table. No new tables, no new sequences. The migration is simple, non-destructive, and reversible (constraints can be dropped). Risk to production deployment: minimal. **5. No new environment variables required** The 100-item cap (`MAX_ITEMS = 100`) is a compile-time constant in `JourneyItemService`, not a configurable environment variable. For a family archive with expected journey sizes well under 100, this is appropriate. If this ever needs tuning, it would require a code change — fine for now. --- ### What's Done Well - No `:latest` image tags (no Docker changes). - No hardcoded secrets. - The migration comment about PgBouncer transaction-mode is the kind of operational documentation that saves time during future configuration changes. - `JacksonConfig.java` placeholder is clean — no dead code, just an extension point comment.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing this PR against issue #751's requirements. The implementation is functionally complete for the backend scope. I'm flagging two requirement ambiguities and one gap that should be tracked.


Requirements Coverage

Requirement (from issue #751) Status
Append journey item (documentId and/or note) Implemented
Note PATCH with three-way semantics (absent/null/value) Implemented
Delete journey item Implemented
Reorder journey items (full-list, atomic) Implemented
100-item capacity cap Implemented
IDOR protection (item belongs to journey guard) Implemented
Permission: BLOG_WRITE on all mutations Implemented
Error codes + i18n in all three languages Implemented
API types regenerated Implemented

Ambiguities to Resolve

1. "At least one of documentId or note" — where is this enforced at the DB level?

The PR enforces the "at least one" constraint in the service layer (JourneyItemService.append). The issue description mentions "A CHECK constraint ensures at least one of document_id or note is present" — but V73 does not add this CHECK constraint. V72 (from PR #750) presumably added the column definition, but the CHECK constraint for (document_id IS NOT NULL OR note IS NOT NULL) appears to be missing from the current migration set.

Recommendation: Verify that this constraint exists in V72. If it does not, create a V74 migration. The service-layer check is a race-condition guard, not a substitute for DB enforcement.

2. Reorder returns items in new order — does the response order match the requested order?

The API description says PUT /{id}/items/reorder returns List<JourneyItemView>. The service:

List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
return reordered.stream().map(this::toView).toList();

saveAll returns items in the order they were passed to it. The order of toSave is derived from the iteration order of requestedIds. This is correct — but the acceptance criteria should explicitly state "response order matches requested order" to prevent confusion if a future refactor changes the saveAll return ordering. This is a documentation gap, not a code bug.


Gap: journey_item_document_deleted i18n key exists but no UI uses it yet

The key journey_item_document_deleted ([Dokument gelöscht] / [Document deleted]) is added to all three message files. This key is intended for the reader UI when a journey item's linked document has been deleted (the FK uses ON DELETE SET NULL). The backend correctly returns document: null in JourneyItemView when the document is gone.

The frontend currently has no component that renders JourneyItemView, so the key is unused. This is correct — the i18n key is being added proactively. It should be tracked in the follow-on reader UI issue (#751 or a new issue) to ensure the UI actually uses it and does not silently render nothing when document === null.


What's Complete

  • The full create/read/update/delete contract for journey items is covered.
  • All four new error codes are mapped to i18n strings in all three supported languages (de/en/es).
  • The journey_item_document_deleted placeholder key is correctly added ahead of the reader UI.
  • ADR-035 captures the three-way PATCH decision, which would otherwise be a requirements ambiguity for future developers.
## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing this PR against issue #751's requirements. The implementation is functionally complete for the backend scope. I'm flagging two requirement ambiguities and one gap that should be tracked. --- ### Requirements Coverage | Requirement (from issue #751) | Status | |---|---| | Append journey item (documentId and/or note) | ✅ Implemented | | Note PATCH with three-way semantics (absent/null/value) | ✅ Implemented | | Delete journey item | ✅ Implemented | | Reorder journey items (full-list, atomic) | ✅ Implemented | | 100-item capacity cap | ✅ Implemented | | IDOR protection (item belongs to journey guard) | ✅ Implemented | | Permission: `BLOG_WRITE` on all mutations | ✅ Implemented | | Error codes + i18n in all three languages | ✅ Implemented | | API types regenerated | ✅ Implemented | --- ### Ambiguities to Resolve **1. "At least one of documentId or note" — where is this enforced at the DB level?** The PR enforces the "at least one" constraint in the service layer (`JourneyItemService.append`). The issue description mentions "A CHECK constraint ensures at least one of `document_id` or `note` is present" — but V73 does not add this CHECK constraint. V72 (from PR #750) presumably added the column definition, but the CHECK constraint for `(document_id IS NOT NULL OR note IS NOT NULL)` appears to be missing from the current migration set. **Recommendation:** Verify that this constraint exists in V72. If it does not, create a V74 migration. The service-layer check is a race-condition guard, not a substitute for DB enforcement. **2. Reorder returns items in new order — does the response order match the requested order?** The API description says `PUT /{id}/items/reorder` returns `List<JourneyItemView>`. The service: ```java List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave); return reordered.stream().map(this::toView).toList(); ``` `saveAll` returns items in the order they were passed to it. The order of `toSave` is derived from the iteration order of `requestedIds`. This is correct — but the acceptance criteria should explicitly state "response order matches requested order" to prevent confusion if a future refactor changes the `saveAll` return ordering. This is a documentation gap, not a code bug. --- ### Gap: `journey_item_document_deleted` i18n key exists but no UI uses it yet The key `journey_item_document_deleted` (`[Dokument gelöscht]` / `[Document deleted]`) is added to all three message files. This key is intended for the reader UI when a journey item's linked document has been deleted (the FK uses `ON DELETE SET NULL`). The backend correctly returns `document: null` in `JourneyItemView` when the document is gone. The frontend currently has no component that renders `JourneyItemView`, so the key is unused. This is correct — the i18n key is being added proactively. It should be tracked in the follow-on reader UI issue (#751 or a new issue) to ensure the UI actually uses it and does not silently render nothing when `document === null`. --- ### What's Complete - The full create/read/update/delete contract for journey items is covered. - All four new error codes are mapped to i18n strings in all three supported languages (de/en/es). - The `journey_item_document_deleted` placeholder key is correctly added ahead of the reader UI. - ADR-035 captures the three-way PATCH decision, which would otherwise be a requirements ambiguity for future developers.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This is a pure backend PR — no Svelte components, no routes, no markup. There is nothing for me to audit on the UI/UX or accessibility front at this stage. My review is scoped to the API contract's implications for future frontend work.


API Contract — Frontend Readiness Notes

1. GeschichteView.persons is a Set<PersonView> — unordered

The persons field in GeschichteView is a Set<GeschichteView.PersonView>. When the frontend renders the list of featured persons on a journey page, the order will be non-deterministic across responses. For the reading experience, a stable visual order (alphabetical by lastName, then firstName) is important for senior users who build a mental model of "who is in this story."

Recommendation for the reader UI PR: sort persons client-side before rendering, or change the backend to return a List<PersonView> sorted by lastName, firstName.

2. DocumentSummary.receiverName returns the canonical first receiver only

The buildCanonicalReceiverName method picks the alphabetically-first receiver and returns only their name, with receiverCount indicating how many total receivers exist. The UI convention should show "Anna Amann +2" or similar when receiverCount > 1. This needs explicit UI treatment in the journey reader component — a silent truncation would confuse the 60+ audience who may not realize there are more recipients.

Track in the reader UI issue.

3. journey_item_document_deleted i18n key — ensure it is visually distinct

When document === null in a JourneyItemView, the reader UI must render [Dokument gelöscht] in a visually distinct style — muted text, perhaps with a line-through or ghost card — so users understand the item is intentionally present but the document is no longer accessible. A plain text label without visual treatment would be confusing.

Track in the reader UI issue.

4. No frontend components or routes introduced — nothing to audit for accessibility

No Svelte files were changed. No WCAG criteria apply to this diff. The i18n keys use appropriate German ("Der Reise-Eintrag wurde nicht gefunden." is clear, non-technical, and direct). The Spanish translation reads naturally. The English translation is correct.


Summary

This PR lays the right foundation. The design decisions that will affect senior users — sorted persons, receiver count display, deleted-document visual treatment — are all in the reader UI layer that follows. I will review that PR with full UX and accessibility scrutiny. Nothing to block here.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This is a pure backend PR — no Svelte components, no routes, no markup. There is nothing for me to audit on the UI/UX or accessibility front at this stage. My review is scoped to the API contract's implications for future frontend work. --- ### API Contract — Frontend Readiness Notes **1. `GeschichteView.persons` is a `Set<PersonView>` — unordered** The `persons` field in `GeschichteView` is a `Set<GeschichteView.PersonView>`. When the frontend renders the list of featured persons on a journey page, the order will be non-deterministic across responses. For the reading experience, a stable visual order (alphabetical by lastName, then firstName) is important for senior users who build a mental model of "who is in this story." **Recommendation for the reader UI PR:** sort `persons` client-side before rendering, or change the backend to return a `List<PersonView>` sorted by `lastName, firstName`. **2. `DocumentSummary.receiverName` returns the canonical first receiver only** The `buildCanonicalReceiverName` method picks the alphabetically-first receiver and returns only their name, with `receiverCount` indicating how many total receivers exist. The UI convention should show `"Anna Amann +2"` or similar when `receiverCount > 1`. This needs explicit UI treatment in the journey reader component — a silent truncation would confuse the 60+ audience who may not realize there are more recipients. **Track in the reader UI issue.** **3. `journey_item_document_deleted` i18n key — ensure it is visually distinct** When `document === null` in a `JourneyItemView`, the reader UI must render `[Dokument gelöscht]` in a visually distinct style — muted text, perhaps with a `line-through` or ghost card — so users understand the item is intentionally present but the document is no longer accessible. A plain text label without visual treatment would be confusing. **Track in the reader UI issue.** **4. No frontend components or routes introduced — nothing to audit for accessibility** No Svelte files were changed. No WCAG criteria apply to this diff. The i18n keys use appropriate German (`"Der Reise-Eintrag wurde nicht gefunden."` is clear, non-technical, and direct). The Spanish translation reads naturally. The English translation is correct. --- ### Summary This PR lays the right foundation. The design decisions that will affect senior users — sorted persons, receiver count display, deleted-document visual treatment — are all in the reader UI layer that follows. I will review that PR with full UX and accessibility scrutiny. Nothing to block here.
marcel added 2 commits 2026-06-08 21:14:37 +02:00
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>
Author
Owner

PR #788 review fixes — all concerns addressed

Task 1 — Restore GeschichteService.getView() with @Transactional(readOnly=true)

Commit f09c7974

Re-injected JourneyItemService into GeschichteService. The dependency chain is clean: GeschichteService → JourneyItemService → GeschichteQueryService — no cycle.

Added getView(UUID id) annotated @Transactional(readOnly=true) that:

  • Calls the existing getById(id) (DRAFT visibility check, 404 if not found)
  • Calls journeyItemService.getItems(id)
  • Assembles and returns the GeschichteView via toView()

Controller's getById() now delegates to a single geschichteService.getView(id) call — the two-transaction gap is closed.

Tests updated:

  • GeschichteControllerTest.getById_returns200_whenFound: now stubs geschichteService.getView(id) only — simpler setup
  • GeschichteControllerTest.getById_returns404_whenServiceThrowsNotFound: stubs getView() instead of getById()
  • GeschichteServiceTest: added @Mock JourneyItemService journeyItemService to @InjectMocks setup; added getView_returns_assembled_view_and_delegates_to_journeyItemService() and getView_throws_NOT_FOUND_when_id_unknown() tests

Task 2 — DB CHECK constraint (no change needed — already present)

The constraint is already in V72__add_journey_items_migrate_geschichten_documents.sql:

CONSTRAINT chk_journey_item_not_empty
    CHECK (document_id IS NOT NULL OR note IS NOT NULL)

No new migration was needed.


Task 3 — Rename getSummaryByIdfindSummaryByIdInternal

Commit 99111273

Renamed in all three locations:

  • DocumentService.java — method declaration
  • JourneyItemService.java — sole production caller
  • JourneyItemServiceTest.java — mock stub

The Javadoc already documented the scope-check bypass; it remains accurate with no further changes needed.


Task 4 — toView() visibility (no change needed)

After Task 1, toView() is called only from getView() within GeschichteService. The controller no longer calls it. The existing package-private access is correct — same-package tests (GeschichteServiceTest, GeschichteServiceIntegrationTest) can access it without any visibility change.


Verification

  • ./mvnw clean package -DskipTestsBUILD SUCCESS after both commits
  • npm run check → no new errors (pre-existing 834 baseline errors are unrelated to these changes)
  • Branch pushed: feat/issue-751-journey-item-crud-api @ 99111273
## PR #788 review fixes — all concerns addressed ### Task 1 — Restore `GeschichteService.getView()` with `@Transactional(readOnly=true)` ✅ **Commit `f09c7974`** Re-injected `JourneyItemService` into `GeschichteService`. The dependency chain is clean: `GeschichteService → JourneyItemService → GeschichteQueryService` — no cycle. Added `getView(UUID id)` annotated `@Transactional(readOnly=true)` that: - Calls the existing `getById(id)` (DRAFT visibility check, 404 if not found) - Calls `journeyItemService.getItems(id)` - Assembles and returns the `GeschichteView` via `toView()` Controller's `getById()` now delegates to a single `geschichteService.getView(id)` call — the two-transaction gap is closed. Tests updated: - `GeschichteControllerTest.getById_returns200_whenFound`: now stubs `geschichteService.getView(id)` only — simpler setup - `GeschichteControllerTest.getById_returns404_whenServiceThrowsNotFound`: stubs `getView()` instead of `getById()` - `GeschichteServiceTest`: added `@Mock JourneyItemService journeyItemService` to `@InjectMocks` setup; added `getView_returns_assembled_view_and_delegates_to_journeyItemService()` and `getView_throws_NOT_FOUND_when_id_unknown()` tests --- ### Task 2 — DB CHECK constraint ✅ (no change needed — already present) The constraint is already in `V72__add_journey_items_migrate_geschichten_documents.sql`: ```sql CONSTRAINT chk_journey_item_not_empty CHECK (document_id IS NOT NULL OR note IS NOT NULL) ``` No new migration was needed. --- ### Task 3 — Rename `getSummaryById` → `findSummaryByIdInternal` ✅ **Commit `99111273`** Renamed in all three locations: - `DocumentService.java` — method declaration - `JourneyItemService.java` — sole production caller - `JourneyItemServiceTest.java` — mock stub The Javadoc already documented the scope-check bypass; it remains accurate with no further changes needed. --- ### Task 4 — `toView()` visibility ✅ (no change needed) After Task 1, `toView()` is called only from `getView()` within `GeschichteService`. The controller no longer calls it. The existing package-private access is correct — same-package tests (`GeschichteServiceTest`, `GeschichteServiceIntegrationTest`) can access it without any visibility change. --- ### Verification - `./mvnw clean package -DskipTests` → **BUILD SUCCESS** after both commits - `npm run check` → no new errors (pre-existing 834 baseline errors are unrelated to these changes) - Branch pushed: `feat/issue-751-journey-item-crud-api` @ `99111273`
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid core: the three-way PATCH semantics with JsonNullable are implemented correctly, the reorder algorithm is clean, and the test coverage across unit + constraint layers is genuinely good work. A few things need fixing before this ships.


Blockers

1. currentUser() calls UserService inside a @Transactional method — potential N+1 per write

In JourneyItemService, every write method calls currentUser() after the main DB work, which in turn calls userService.findByEmail(auth.getName()) — a second DB round-trip inside the transaction. This adds latency to every append/updateNote/delete call and is unnecessary: the AppUser principal is already available in the Spring Security context as the fully-hydrated user object (if loaded that way). Either inject the user lookup once at the top of each method and reuse it, or resolve it from the principal directly. The current pattern calls findByEmail on every write even though auth is already established.

2. append() does count + findMaxPosition — two separate queries that could race

In JourneyItemService.append():

long count = journeyItemRepository.countByGeschichteId(geschichteId);  // query 1
// ... capacity check ...
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)  // query 2

Both run inside the transaction but between them another concurrent append can sneak in. The DEFERRABLE constraint prevents a corrupt position, but countByGeschichteId can then return a stale count (the capacity check may pass when it shouldn't). Consider combining into a single findMaxPositionAndCount projection query.

3. JOURNEY_ITEM_NOTE_UPDATED is in AuditKind but NOT in ErrorCode.java / errors.ts

The PR summary lists JOURNEY_ITEM_NOT_FOUND, JOURNEY_ITEM_POSITION_CONFLICT, JOURNEY_AT_CAPACITY, GESCHICHTE_TYPE_MISMATCH as ErrorCodes (all present) — but JOURNEY_ITEM_NOTE_UPDATED is an AuditKind, not an ErrorCode. That's correct and intended. However, CLAUDE.md requires: when adding a new ErrorCode, add the i18n key. The four new error codes ARE added to errors.tsgetErrorMessage(), but the corresponding i18n message keys (error_journey_item_not_found, etc.) need to be present in messages/{de,en,es}.json. The diff does not show those files being updated. This would cause a runtime m.error_journey_item_not_found is not a function error in the frontend the moment one of these codes surfaces. Blocker.


Suggestions

4. reorder() builds HashMap then iterates requestedIds — use Stream instead

// Current:
Map<UUID, JourneyItem> itemMap = new HashMap<>();
for (JourneyItem item : items) { itemMap.put(item.getId(), item); }
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
for (int i = 0; i < requestedIds.size(); i++) {
    JourneyItem item = itemMap.get(requestedIds.get(i));
    item.setPosition((i + 1) * POSITION_STEP);
    toSave.add(item);
}

// Cleaner:
Map<UUID, JourneyItem> itemMap = items.stream()
    .collect(Collectors.toMap(JourneyItem::getId, Function.identity()));
List<JourneyItem> toSave = IntStream.range(0, requestedIds.size())
    .mapToObj(i -> {
        JourneyItem item = itemMap.get(requestedIds.get(i));
        item.setPosition((i + 1) * POSITION_STEP);
        return item;
    }).toList();

5. GeschichteView record — AuthorView fields lack @Schema(requiredMode = REQUIRED) on non-null fields

AuthorView(String firstName, String lastName, String displayName) — these are always populated when an author exists. Without REQUIRED, the TypeScript types will be optional (string | undefined) and frontend code will need unnecessary null guards. Check all three fields.

6. GeschichteQueryService javadoc comment is cut off

The comment reads: "Exists so that JourneyItemService can check Geschichte existence and load Geschichte instances without holding a direct reference to the Geschichte repository (cross-dom" — it's truncated mid-word in the diff. Make sure the source file has the complete sentence.

7. Test factory method naming: makeDoc vs makeDocument

JourneyItemServiceTest uses makeDoc(...) while most of the test codebase uses makeDocument(...). Minor inconsistency — pick one and be consistent within the test file at minimum.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid core: the three-way PATCH semantics with `JsonNullable` are implemented correctly, the `reorder` algorithm is clean, and the test coverage across unit + constraint layers is genuinely good work. A few things need fixing before this ships. --- ### Blockers **1. `currentUser()` calls UserService inside a `@Transactional` method — potential N+1 per write** In `JourneyItemService`, every write method calls `currentUser()` *after* the main DB work, which in turn calls `userService.findByEmail(auth.getName())` — a second DB round-trip inside the transaction. This adds latency to every append/updateNote/delete call and is unnecessary: the `AppUser` principal is already available in the Spring Security context as the fully-hydrated user object (if loaded that way). Either inject the user lookup once at the top of each method and reuse it, or resolve it from the principal directly. The current pattern calls `findByEmail` on every write even though auth is already established. **2. `append()` does `count` + `findMaxPosition` — two separate queries that could race** In `JourneyItemService.append()`: ```java long count = journeyItemRepository.countByGeschichteId(geschichteId); // query 1 // ... capacity check ... int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId) // query 2 ``` Both run inside the transaction but between them another concurrent append can sneak in. The DEFERRABLE constraint prevents a corrupt position, but `countByGeschichteId` can then return a stale count (the capacity check may pass when it shouldn't). Consider combining into a single `findMaxPositionAndCount` projection query. **3. `JOURNEY_ITEM_NOTE_UPDATED` is in `AuditKind` but NOT in `ErrorCode.java` / `errors.ts`** The PR summary lists `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`, `JOURNEY_AT_CAPACITY`, `GESCHICHTE_TYPE_MISMATCH` as ErrorCodes (all present) — but `JOURNEY_ITEM_NOTE_UPDATED` is an `AuditKind`, not an `ErrorCode`. That's correct and intended. However, `CLAUDE.md` requires: when adding a new `ErrorCode`, add the i18n key. The four new error codes ARE added to `errors.ts` → `getErrorMessage()`, but the corresponding i18n message keys (`error_journey_item_not_found`, etc.) need to be present in `messages/{de,en,es}.json`. The diff does not show those files being updated. This would cause a runtime `m.error_journey_item_not_found is not a function` error in the frontend the moment one of these codes surfaces. **Blocker.** --- ### Suggestions **4. `reorder()` builds `HashMap` then iterates `requestedIds` — use `Stream` instead** ```java // Current: Map<UUID, JourneyItem> itemMap = new HashMap<>(); for (JourneyItem item : items) { itemMap.put(item.getId(), item); } List<JourneyItem> toSave = new ArrayList<>(requestedIds.size()); for (int i = 0; i < requestedIds.size(); i++) { JourneyItem item = itemMap.get(requestedIds.get(i)); item.setPosition((i + 1) * POSITION_STEP); toSave.add(item); } // Cleaner: Map<UUID, JourneyItem> itemMap = items.stream() .collect(Collectors.toMap(JourneyItem::getId, Function.identity())); List<JourneyItem> toSave = IntStream.range(0, requestedIds.size()) .mapToObj(i -> { JourneyItem item = itemMap.get(requestedIds.get(i)); item.setPosition((i + 1) * POSITION_STEP); return item; }).toList(); ``` **5. `GeschichteView` record — `AuthorView` fields lack `@Schema(requiredMode = REQUIRED)` on non-null fields** `AuthorView(String firstName, String lastName, String displayName)` — these are always populated when an author exists. Without `REQUIRED`, the TypeScript types will be optional (`string | undefined`) and frontend code will need unnecessary null guards. Check all three fields. **6. `GeschichteQueryService` javadoc comment is cut off** The comment reads: *"Exists so that `JourneyItemService` can check Geschichte existence and load Geschichte instances without holding a direct reference to the Geschichte repository (cross-dom"* — it's truncated mid-word in the diff. Make sure the source file has the complete sentence. **7. Test factory method naming: `makeDoc` vs `makeDocument`** `JourneyItemServiceTest` uses `makeDoc(...)` while most of the test codebase uses `makeDocument(...)`. Minor inconsistency — pick one and be consistent within the test file at minimum.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

The structural decisions here are sound. The GeschichteQueryService split to break the cyclic dependency between JourneyItemService and GeschichteService is exactly right — constructor injection cycle avoidance without @Lazy, just like the codebase guidance requires. The journeyitem/ sub-package under geschichte/ is the correct placement. One genuine blocker and a few things worth tracking.


Blockers

1. DocumentSummary record lives in the geschichte package — boundary violation

DocumentSummary is defined in org.raddatz.familienarchiv.geschichte.DocumentSummary. It is a lean view of a Document entity, but it belongs to the geschichte domain. That means the document domain cannot use it, and if it ever needs to (e.g., a future document detail page in a different domain context), you'll have a circular import. The conventional placement is either inside geschichte/journeyitem/ (as a package-private record used only there) or — if it will be reused — a shared read-model in document/. Currently it's only used by JourneyItemService.toSummary(), so geschichte/journeyitem/DocumentSummary.java or even an inner record would be cleaner.


Documentation Gaps (Architect's hard rule: doc omission = blocker, not concern)

2. New GeschichteQueryService is not reflected in the C4 backend diagram

Per the doc-update table in both CLAUDE.md and my review rule: a new Spring Boot service in an existing domain requires updating docs/architecture/c4/l3-backend-geschichten.puml (or equivalent). The diff shows CLAUDE.md was updated with GeschichteQueryService in the package listing, which is good — but the C4 L3 diagram was not updated. Blocker until the diagram is current.

3. DocumentService.findSummaryByIdInternal — new public method in document domain; C4 diagram needs update

Similarly, a new public service method on DocumentService in the document domain should be reflected in the backend C4 diagram for that domain if it represents a meaningful cross-domain interface point. Given the Internal suffix signals it's an internal-only boundary, the doc update may be minimal — but it needs to be there.


Architectural Observations (Suggestions)

4. The reorder endpoint uses PUT for full replacement — correct and intentional, well-documented via @Operation

Using PUT /{id}/items/reorder is semantically correct: the client sends the complete ordered list and it replaces the current order. This is consistent with REST conventions and the @Operation description makes the all-IDs-required semantics explicit. Good call.

5. The DEFERRABLE INITIALLY DEFERRED constraint comment in V73 is exactly right

The migration comment noting the PgBouncer transaction-mode dependency is precisely the kind of consequence documentation an ADR would normally capture. Since this decision (deferrable constraints work only in transaction-mode pooling) has lasting infrastructure consequences, consider whether a brief ADR entry is warranted — or at minimum a note in docs/DEPLOYMENT.md linking the constraint name to the pooling requirement, so a future infrastructure change doesn't silently break deferred checking.

6. Capacity check (MAX_ITEMS = 100) is application-only — no database-layer enforcement

The 100-item cap is enforced only in JourneyItemService.append(). A bug, a direct SQL insert, or a future batch operation could bypass it. For truly important invariants, a CHECK (position <= 1000) equivalent or a BEFORE INSERT trigger would enforce it at the DB layer. Given this is a family archive with modest data volumes, application-layer enforcement is acceptable — but I'd document the deliberate choice rather than leaving it implied.

## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** The structural decisions here are sound. The `GeschichteQueryService` split to break the cyclic dependency between `JourneyItemService` and `GeschichteService` is exactly right — constructor injection cycle avoidance without `@Lazy`, just like the codebase guidance requires. The `journeyitem/` sub-package under `geschichte/` is the correct placement. One genuine blocker and a few things worth tracking. --- ### Blockers **1. `DocumentSummary` record lives in the `geschichte` package — boundary violation** `DocumentSummary` is defined in `org.raddatz.familienarchiv.geschichte.DocumentSummary`. It is a lean view of a `Document` entity, but it belongs to the `geschichte` domain. That means the `document` domain cannot use it, and if it ever needs to (e.g., a future document detail page in a different domain context), you'll have a circular import. The conventional placement is either inside `geschichte/journeyitem/` (as a package-private record used only there) or — if it will be reused — a shared read-model in `document/`. Currently it's only used by `JourneyItemService.toSummary()`, so `geschichte/journeyitem/DocumentSummary.java` or even an inner record would be cleaner. --- ### Documentation Gaps (Architect's hard rule: doc omission = blocker, not concern) **2. New `GeschichteQueryService` is not reflected in the C4 backend diagram** Per the doc-update table in both CLAUDE.md and my review rule: a new Spring Boot service in an existing domain requires updating `docs/architecture/c4/l3-backend-geschichten.puml` (or equivalent). The diff shows `CLAUDE.md` was updated with `GeschichteQueryService` in the package listing, which is good — but the C4 L3 diagram was not updated. **Blocker until the diagram is current.** **3. `DocumentService.findSummaryByIdInternal` — new public method in document domain; C4 diagram needs update** Similarly, a new public service method on `DocumentService` in the document domain should be reflected in the backend C4 diagram for that domain if it represents a meaningful cross-domain interface point. Given the `Internal` suffix signals it's an internal-only boundary, the doc update may be minimal — but it needs to be there. --- ### Architectural Observations (Suggestions) **4. The `reorder` endpoint uses `PUT` for full replacement — correct and intentional, well-documented via `@Operation`** Using `PUT /{id}/items/reorder` is semantically correct: the client sends the complete ordered list and it replaces the current order. This is consistent with REST conventions and the `@Operation` description makes the all-IDs-required semantics explicit. Good call. **5. The `DEFERRABLE INITIALLY DEFERRED` constraint comment in V73 is exactly right** The migration comment noting the PgBouncer transaction-mode dependency is precisely the kind of consequence documentation an ADR would normally capture. Since this decision (deferrable constraints work only in transaction-mode pooling) has lasting infrastructure consequences, consider whether a brief ADR entry is warranted — or at minimum a note in `docs/DEPLOYMENT.md` linking the constraint name to the pooling requirement, so a future infrastructure change doesn't silently break deferred checking. **6. Capacity check (MAX_ITEMS = 100) is application-only — no database-layer enforcement** The 100-item cap is enforced only in `JourneyItemService.append()`. A bug, a direct SQL insert, or a future batch operation could bypass it. For truly important invariants, a `CHECK (position <= 1000)` equivalent or a `BEFORE INSERT` trigger would enforce it at the DB layer. Given this is a family archive with modest data volumes, application-layer enforcement is acceptable — but I'd document the deliberate choice rather than leaving it implied.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Good security posture overall. IDOR protection is in place via findByIdAndGeschichteId (item ID alone is insufficient to access an item — the geschichteId path parameter must also match). @RequirePermission(Permission.BLOG_WRITE) is correctly applied to all write endpoints. The error handler does not leak SQL or constraint values (CWE-209). One genuine security concern and one smell to address.


Blockers

1. findSummaryByIdInternal deliberately skips authorization — the comment confirms this, and it's a risk surface that needs hardening documentation

In DocumentService:

/**
 * Lightweight summary lookup for internal use...
 * Intentionally skips scope checks and tag-colour resolution — safe only
 * under the current single-tenant model where all authenticated users share
 * the same document scope.
 */
public Document findSummaryByIdInternal(UUID id) {
    return documentRepository.findById(id)
        .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, ...));
}

The comment acknowledges the skipped auth. Under a single-tenant model this is currently safe. But:

  1. There is no test that verifies this method is only called from within a @Transactional context where the caller has already validated BLOG_WRITE permission.
  2. The method name Internal is a naming convention, not an enforcement mechanism. A future contributor could call it from an unauthenticated code path without noticing the caveat.

Fix: Either (a) make the method package-private and add a @VisibleForTesting annotation, or (b) add a comment pointing to the permission check that must have occurred before calling this — and add a test to JourneyItemServiceTest that verifies the append path checks permissions before calling the document lookup. Currently the unit tests mock the auth context but don't explicitly verify the permission boundary is enforced before the document fetch.


Concerns (not hard blockers, but worth addressing)

2. reorder() validates that requestedIds matches existingIds exactly — good. But the implementation uses HashSet equality which ignores order

The code correctly checks existingIds.equals(new HashSet<>(requestedIds)) to verify the same items are present. This is fine for completeness validation. The IDOR surface is the findByIdAndGeschichteId usage in per-item operations. The reorder endpoint does not do per-item IDOR checks — it validates set equality instead. This is logically correct (if the sets match, all IDs belong to this journey) but confirm that findIdsByGeschichteId is scoped to the geschichteId parameter and not returning a global query. Based on the repository definition this appears correct.

3. No test for GESCHICHTE_TYPE_MISMATCH error code path

The append() method throws GESCHICHTE_TYPE_MISMATCH when a client tries to add items to a STORY-type Geschichte. This is a security-relevant guard (prevents misuse of the API). There should be a unit test in JourneyItemServiceTest that verifies this 409 is returned when g.getType() != GeschichteType.JOURNEY. Without the test, a refactor could silently remove this guard.

4. GlobalExceptionHandler — constraint name mapping is in a public handler method

The code correctly logs only the constraint name (not the SQL or values). The string comparison "uq_journey_items_geschichte_position".equals(constraint) is fine — it's an exact match, not a contains() that could be spoofed. This is correct. Just noting it as reviewed-and-intentional.


Positive Findings

  • findByIdAndGeschichteId is the right IDOR guard — item ID alone cannot be used to access or delete another journey's items.
  • Error messages in DomainException calls include the item ID for debugging but not sensitive user data.
  • SLF4J parameterized logging is used consistently throughout — no string concatenation in log calls (Log4Shell prevention).
## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** Good security posture overall. IDOR protection is in place via `findByIdAndGeschichteId` (item ID alone is insufficient to access an item — the `geschichteId` path parameter must also match). `@RequirePermission(Permission.BLOG_WRITE)` is correctly applied to all write endpoints. The error handler does not leak SQL or constraint values (CWE-209). One genuine security concern and one smell to address. --- ### Blockers **1. `findSummaryByIdInternal` deliberately skips authorization — the comment confirms this, and it's a risk surface that needs hardening documentation** In `DocumentService`: ```java /** * Lightweight summary lookup for internal use... * Intentionally skips scope checks and tag-colour resolution — safe only * under the current single-tenant model where all authenticated users share * the same document scope. */ public Document findSummaryByIdInternal(UUID id) { return documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, ...)); } ``` The comment acknowledges the skipped auth. Under a single-tenant model this is currently safe. But: 1. There is no test that verifies this method is only called from within a `@Transactional` context where the caller has already validated `BLOG_WRITE` permission. 2. The method name `Internal` is a naming convention, not an enforcement mechanism. A future contributor could call it from an unauthenticated code path without noticing the caveat. **Fix**: Either (a) make the method package-private and add a `@VisibleForTesting` annotation, or (b) add a comment pointing to the permission check that must have occurred before calling this — and add a test to `JourneyItemServiceTest` that verifies the `append` path checks permissions before calling the document lookup. Currently the unit tests mock the auth context but don't explicitly verify the permission boundary is enforced before the document fetch. --- ### Concerns (not hard blockers, but worth addressing) **2. `reorder()` validates that `requestedIds` matches `existingIds` exactly — good. But the implementation uses `HashSet` equality which ignores order** The code correctly checks `existingIds.equals(new HashSet<>(requestedIds))` to verify the same items are present. This is fine for completeness validation. The IDOR surface is the `findByIdAndGeschichteId` usage in per-item operations. The reorder endpoint does not do per-item IDOR checks — it validates set equality instead. This is logically correct (if the sets match, all IDs belong to this journey) but confirm that `findIdsByGeschichteId` is scoped to the `geschichteId` parameter and not returning a global query. Based on the repository definition this appears correct. **3. No test for `GESCHICHTE_TYPE_MISMATCH` error code path** The `append()` method throws `GESCHICHTE_TYPE_MISMATCH` when a client tries to add items to a STORY-type Geschichte. This is a security-relevant guard (prevents misuse of the API). There should be a unit test in `JourneyItemServiceTest` that verifies this 409 is returned when `g.getType() != GeschichteType.JOURNEY`. Without the test, a refactor could silently remove this guard. **4. `GlobalExceptionHandler` — constraint name mapping is in a public handler method** The code correctly logs only the constraint name (not the SQL or values). The string comparison `"uq_journey_items_geschichte_position".equals(constraint)` is fine — it's an exact match, not a contains() that could be spoofed. This is correct. Just noting it as reviewed-and-intentional. --- ### Positive Findings - `findByIdAndGeschichteId` is the right IDOR guard — item ID alone cannot be used to access or delete another journey's items. - Error messages in `DomainException` calls include the item ID for debugging but not sensitive user data. - SLF4J parameterized logging is used consistently throughout — no string concatenation in log calls (Log4Shell prevention).
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

The test strategy is strong at the unit layer. JourneyItemServiceTest has excellent coverage of the happy path and most error branches. JourneyItemConstraintsTest using real Postgres via Testcontainers (not H2) for the deferrable constraint verification is exactly right — that test would silently pass on H2 and mean nothing. A few critical coverage gaps remain.


Blockers

1. No @WebMvcTest slice for the new JourneyItem endpoints on GeschichteController

The diff adds four new endpoints to GeschichteController:

  • POST /{id}/items
  • PATCH /{id}/items/{itemId}
  • DELETE /{id}/items/{itemId}
  • PUT /{id}/items/reorder

GeschichteControllerTest is updated for getById returning GeschichteView, but there are no @WebMvcTest tests covering:

  • Happy path for each new endpoint
  • 403 when caller lacks BLOG_WRITE (unauthorized user test)
  • 401 when unauthenticated
  • 404 when geschichteId not found
  • 400 when body is malformed JSON

The @RequirePermission(Permission.BLOG_WRITE) annotation means all four endpoints should have a 403 test. Without these, permission enforcement is untested at the HTTP layer. Blocker per the project's quality gate.

2. No test for GESCHICHTE_TYPE_MISMATCH code path in JourneyItemServiceTest

append() throws this error when called on a STORY-type Geschichte. This error path has no test. Given it's a guard against misuse, it should be explicitly tested:

@Test
void append_throws_TYPE_MISMATCH_when_geschichte_is_STORY() {
    Geschichte story = Geschichte.builder()
        .id(geschichteId).type(GeschichteType.STORY)
        .status(GeschichteStatus.PUBLISHED).build();
    when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
    
    assertThatThrownBy(() -> journeyItemService.append(geschichteId, new JourneyItemCreateDTO()))
        .isInstanceOf(DomainException.class)
        .extracting("code")
        .isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH);
}

3. Missing i18n keys for new error codes will cause npm run check to fail

As Felix noted, the four new ErrorCode values added to errors.tsgetErrorMessage() call Paraglide message functions (m.error_journey_item_not_found(), etc.) that don't appear to be defined in messages/{de,en,es}.json based on the diff. This will fail the svelte-check baseline and potentially break the frontend build. Should be caught before merge.


Suggestions

4. JourneyItemConstraintsTest@BeforeEach seed uses DELETE FROM journey_items but not within a transaction

The test class comment correctly explains why @Transactional is NOT used at the class level (to avoid rollback-only cascade on DataIntegrityViolationException). The DELETE FROM journey_items in @BeforeEach is the right cleanup approach. However, this DELETE is a raw JDBC call that cascades correctly — worth noting this could leave orphan data in geschichten and documents tables across test runs if the test fails before reaching the @BeforeEach. Consider adding DELETE FROM geschichten WHERE title = 'Constraints-Test-Journey' to the teardown or using unique titles per test run.

5. reorder_emits_audit_event test — should also verify getItems returns items in new order

The reorder unit test (reorder_logs_audit_event_after_commit) only verifies the audit call. It doesn't verify that positions were actually updated on the returned views. Add a position-order assertion:

assertThat(views).extracting(JourneyItemView::position)
    .containsExactly(10, 20, 30);

6. Test coverage for reorder with an empty journey

When requestedIds is empty and existingIds is also empty, reorder() returns List.of() early. There's no test for the empty-journey case. Worth adding since the early-return path is untested:

@Test
void reorder_empty_journey_returns_empty_list() { ... }

Positive Findings

  • JourneyItemConstraintsTest uses real PostgreSQL via PostgresContainerConfig — the deferrable constraint verification is genuinely meaningful.
  • updateNote_absent_leaves_note_unchanged tests the three-way PATCH no-op path — this is the hardest semantic to verify and it's tested correctly.
  • append_rejects_when_both_documentId_and_note_are_null — explicit test for the "at least one required" validation rule.
## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** The test strategy is strong at the unit layer. `JourneyItemServiceTest` has excellent coverage of the happy path and most error branches. `JourneyItemConstraintsTest` using real Postgres via Testcontainers (not H2) for the deferrable constraint verification is exactly right — that test would silently pass on H2 and mean nothing. A few critical coverage gaps remain. --- ### Blockers **1. No `@WebMvcTest` slice for the new JourneyItem endpoints on `GeschichteController`** The diff adds four new endpoints to `GeschichteController`: - `POST /{id}/items` - `PATCH /{id}/items/{itemId}` - `DELETE /{id}/items/{itemId}` - `PUT /{id}/items/reorder` `GeschichteControllerTest` is updated for `getById` returning `GeschichteView`, but there are no `@WebMvcTest` tests covering: - Happy path for each new endpoint - 403 when caller lacks `BLOG_WRITE` (unauthorized user test) - 401 when unauthenticated - 404 when `geschichteId` not found - 400 when body is malformed JSON The `@RequirePermission(Permission.BLOG_WRITE)` annotation means all four endpoints should have a 403 test. Without these, permission enforcement is untested at the HTTP layer. **Blocker** per the project's quality gate. **2. No test for `GESCHICHTE_TYPE_MISMATCH` code path in `JourneyItemServiceTest`** `append()` throws this error when called on a STORY-type Geschichte. This error path has no test. Given it's a guard against misuse, it should be explicitly tested: ```java @Test void append_throws_TYPE_MISMATCH_when_geschichte_is_STORY() { Geschichte story = Geschichte.builder() .id(geschichteId).type(GeschichteType.STORY) .status(GeschichteStatus.PUBLISHED).build(); when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story)); assertThatThrownBy(() -> journeyItemService.append(geschichteId, new JourneyItemCreateDTO())) .isInstanceOf(DomainException.class) .extracting("code") .isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH); } ``` **3. Missing i18n keys for new error codes will cause `npm run check` to fail** As Felix noted, the four new `ErrorCode` values added to `errors.ts` → `getErrorMessage()` call Paraglide message functions (`m.error_journey_item_not_found()`, etc.) that don't appear to be defined in `messages/{de,en,es}.json` based on the diff. This will fail the `svelte-check` baseline and potentially break the frontend build. Should be caught before merge. --- ### Suggestions **4. `JourneyItemConstraintsTest` — `@BeforeEach` seed uses `DELETE FROM journey_items` but not within a transaction** The test class comment correctly explains why `@Transactional` is NOT used at the class level (to avoid rollback-only cascade on `DataIntegrityViolationException`). The `DELETE FROM journey_items` in `@BeforeEach` is the right cleanup approach. However, this `DELETE` is a raw JDBC call that cascades correctly — worth noting this could leave orphan data in `geschichten` and `documents` tables across test runs if the test fails before reaching the `@BeforeEach`. Consider adding `DELETE FROM geschichten WHERE title = 'Constraints-Test-Journey'` to the teardown or using unique titles per test run. **5. `reorder_emits_audit_event` test — should also verify `getItems` returns items in new order** The reorder unit test (`reorder_logs_audit_event_after_commit`) only verifies the audit call. It doesn't verify that positions were actually updated on the returned views. Add a position-order assertion: ```java assertThat(views).extracting(JourneyItemView::position) .containsExactly(10, 20, 30); ``` **6. Test coverage for `reorder` with an empty journey** When `requestedIds` is empty and `existingIds` is also empty, `reorder()` returns `List.of()` early. There's no test for the empty-journey case. Worth adding since the early-return path is untested: ```java @Test void reorder_empty_journey_returns_empty_list() { ... } ``` --- ### Positive Findings - `JourneyItemConstraintsTest` uses real PostgreSQL via `PostgresContainerConfig` — the deferrable constraint verification is genuinely meaningful. - `updateNote_absent_leaves_note_unchanged` tests the three-way PATCH no-op path — this is the hardest semantic to verify and it's tested correctly. - `append_rejects_when_both_documentId_and_note_are_null` — explicit test for the "at least one required" validation rule.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

This PR is purely application-layer — no Docker Compose changes, no new infrastructure, no CI workflow modifications. Flyway V73 migration will run cleanly on the existing PostgreSQL 16 container; the DEFERRABLE INITIALLY DEFERRED constraint is supported natively. The jackson-databind-nullable 0.2.6 dependency addition to pom.xml is a small library with no operational footprint.


Observations (not blockers)

1. The V73 migration comment explicitly calls out the PgBouncer transaction-mode dependency — infrastructure team needs to be aware

The migration says:

"Requires transaction-level or session-level connection pooling (prod uses PgBouncer in transaction mode — correct today; a future switch to statement-level would silently break deferred checking at COMMIT)."

This is the right place to document this. From an infrastructure perspective: if we ever reconfigure PgBouncer to pool_mode = statement, the DEFERRABLE INITIALLY DEFERRED constraint silently stops working because deferred constraints require the connection to persist for the full transaction. The current transaction mode is correct. Add this to the infrastructure runbook / docs/DEPLOYMENT.md so it's not just in a migration comment that gets buried.

2. jackson-databind-nullable is a new production dependency — check it's included in OWASP dependency scan

The CI pipeline should be running ./mvnw dependency-check:check or similar as part of the build. jackson-databind-nullable 0.2.6 is a small OpenAPI Tools library with minimal attack surface, but confirm it's picked up by whatever CVE scanning is in place so future vulnerability disclosures are caught automatically.

3. No infrastructure changes needed — good

Adding JourneyItem CRUD to an existing REST controller on an existing service with an existing database keeps operational complexity flat. The only schema change (V73) adds two constraints to an existing table — this is a non-destructive migration that will run in milliseconds on the current data volume.


Summary

Clean PR from an infrastructure perspective. The migration is safe, the dependency is minimal, and nothing in the CI/CD pipeline needs updating. The PgBouncer note is the one thing worth propagating to the infrastructure documentation.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** This PR is purely application-layer — no Docker Compose changes, no new infrastructure, no CI workflow modifications. Flyway V73 migration will run cleanly on the existing PostgreSQL 16 container; the `DEFERRABLE INITIALLY DEFERRED` constraint is supported natively. The `jackson-databind-nullable 0.2.6` dependency addition to `pom.xml` is a small library with no operational footprint. --- ### Observations (not blockers) **1. The V73 migration comment explicitly calls out the PgBouncer transaction-mode dependency — infrastructure team needs to be aware** The migration says: > *"Requires transaction-level or session-level connection pooling (prod uses PgBouncer in transaction mode — correct today; a future switch to statement-level would silently break deferred checking at COMMIT)."* This is the right place to document this. From an infrastructure perspective: if we ever reconfigure PgBouncer to `pool_mode = statement`, the `DEFERRABLE INITIALLY DEFERRED` constraint silently stops working because deferred constraints require the connection to persist for the full transaction. The current transaction mode is correct. Add this to the infrastructure runbook / `docs/DEPLOYMENT.md` so it's not just in a migration comment that gets buried. **2. `jackson-databind-nullable` is a new production dependency — check it's included in OWASP dependency scan** The CI pipeline should be running `./mvnw dependency-check:check` or similar as part of the build. `jackson-databind-nullable 0.2.6` is a small OpenAPI Tools library with minimal attack surface, but confirm it's picked up by whatever CVE scanning is in place so future vulnerability disclosures are caught automatically. **3. No infrastructure changes needed — good** Adding JourneyItem CRUD to an existing REST controller on an existing service with an existing database keeps operational complexity flat. The only schema change (V73) adds two constraints to an existing table — this is a non-destructive migration that will run in milliseconds on the current data volume. --- ### Summary Clean PR from an infrastructure perspective. The migration is safe, the dependency is minimal, and nothing in the CI/CD pipeline needs updating. The PgBouncer note is the one thing worth propagating to the infrastructure documentation.
Author
Owner

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

Verdict: Approved

This is a backend API and data model PR — no frontend components, no Svelte files, no UI markup are changed. The changes that do touch the frontend (errors.ts, messages/ i18n keys, api.ts type regeneration) are infrastructure for the UI that will be built on top of this layer. My review is limited to what's here, with observations for what the UI work needs to address.


No UX/Accessibility Blockers in This PR

The only frontend-touching changes are:

  1. frontend/src/lib/shared/errors.ts — four new error codes added to getErrorMessage() switch
  2. frontend/src/api.ts — regenerated types (no UI changes)
  3. The PR is explicitly marked WIP: tasks 9 (frontend error codes + i18n + generate:api) are incomplete

These are pre-conditions for UI work, not UI work itself. No accessibility violations to flag here.


Forward-Looking Notes for the UI Sprint

When the JourneyItem UI is built on top of this API, flag these concerns for the frontend PR:

1. The reorder endpoint's UX contract is strict — the UI must send ALL item IDs

PUT /{id}/items/reorder requires the complete ordered list. If the frontend only sends a partial list (e.g., the items visible in the viewport), the server returns a 400. The drag-and-drop component must manage the full ordered state client-side, not just the moved item. This is a non-obvious constraint that needs to be surfaced in the component's design.

2. JOURNEY_AT_CAPACITY (100 items) — the UI needs a proactive state, not just an error response

When a journey has 100 items, the "Add item" affordance should be disabled/hidden rather than letting the user attempt the action and receive a 400. The API response alone is insufficient UX for the senior audience. The GeschichteView response includes the item list — the frontend can derive isAtCapacity = items.length >= 100 and hide the add button accordingly.

3. note field character limit (5000 chars) — needs visible counter in the edit UI

The service enforces MAX_NOTE_LENGTH = 5000. The frontend note input needs a character counter that activates as the user approaches the limit (>4000 chars = warning indicator). The senior audience especially benefits from visible progress on text limits.

4. DocumentSummary in JourneyItemView — sparse data, be careful with display assumptions

DocumentSummary only includes senderName and receiverName as strings (not full Person objects). These can be null if the document has no sender metadata. The JourneyItem card component must handle null sender/receiver gracefully — empty strings or a fallback like "Unbekannt" rather than null displayed raw.

5. Accessibility: drag-to-reorder requires keyboard alternative

Any drag-and-drop reorder UI built on top of the reorder endpoint must provide a keyboard-accessible alternative (e.g., move up/move down buttons). This is a WCAG 2.1 success criterion 2.1.1 (Keyboard). The PUT /reorder endpoint supports this natively since it accepts any ordering.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ✅ Approved** This is a backend API and data model PR — no frontend components, no Svelte files, no UI markup are changed. The changes that do touch the frontend (`errors.ts`, `messages/` i18n keys, `api.ts` type regeneration) are infrastructure for the UI that will be built on top of this layer. My review is limited to what's here, with observations for what the UI work needs to address. --- ### No UX/Accessibility Blockers in This PR The only frontend-touching changes are: 1. `frontend/src/lib/shared/errors.ts` — four new error codes added to `getErrorMessage()` switch 2. `frontend/src/api.ts` — regenerated types (no UI changes) 3. The PR is explicitly marked WIP: tasks 9 (frontend error codes + i18n + generate:api) are incomplete These are pre-conditions for UI work, not UI work itself. No accessibility violations to flag here. --- ### Forward-Looking Notes for the UI Sprint When the JourneyItem UI is built on top of this API, flag these concerns for the frontend PR: **1. The `reorder` endpoint's UX contract is strict — the UI must send ALL item IDs** `PUT /{id}/items/reorder` requires the complete ordered list. If the frontend only sends a partial list (e.g., the items visible in the viewport), the server returns a 400. The drag-and-drop component must manage the full ordered state client-side, not just the moved item. This is a non-obvious constraint that needs to be surfaced in the component's design. **2. `JOURNEY_AT_CAPACITY` (100 items) — the UI needs a proactive state, not just an error response** When a journey has 100 items, the "Add item" affordance should be disabled/hidden rather than letting the user attempt the action and receive a 400. The API response alone is insufficient UX for the senior audience. The `GeschichteView` response includes the item list — the frontend can derive `isAtCapacity = items.length >= 100` and hide the add button accordingly. **3. `note` field character limit (5000 chars) — needs visible counter in the edit UI** The service enforces `MAX_NOTE_LENGTH = 5000`. The frontend note input needs a character counter that activates as the user approaches the limit (`>4000` chars = warning indicator). The senior audience especially benefits from visible progress on text limits. **4. `DocumentSummary` in `JourneyItemView` — sparse data, be careful with display assumptions** `DocumentSummary` only includes `senderName` and `receiverName` as strings (not full `Person` objects). These can be `null` if the document has no sender metadata. The JourneyItem card component must handle null sender/receiver gracefully — empty strings or a fallback like "Unbekannt" rather than `null` displayed raw. **5. Accessibility: drag-to-reorder requires keyboard alternative** Any drag-and-drop reorder UI built on top of the `reorder` endpoint must provide a keyboard-accessible alternative (e.g., move up/move down buttons). This is a WCAG 2.1 success criterion 2.1.1 (Keyboard). The `PUT /reorder` endpoint supports this natively since it accepts any ordering.
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Verdict: ⚠️ Approved with concerns

Reviewing this PR through the requirements and product lens: does the implemented API correctly and completely serve the use cases described in issue #751? The implementation is mostly well-aligned with the domain intent, but several requirements-level gaps warrant attention before the UI sprint begins.


Blockers

1. The GeschichteView response omits type — the frontend cannot distinguish STORY from JOURNEY

The diff shows the Geschichte schema in api.ts now lacks the type field:

-            /** @enum {string} */
-            type: "STORY" | "JOURNEY";

The GeschichteView record (the new read model returned by GET /api/geschichten/{id}) appears to also omit type. Without type in the response, a frontend component rendering a journey detail page cannot confirm it is rendering a JOURNEY rather than a STORY — which means it cannot safely show or hide the item management UI. The GeschichteView must include type (or a boolean isJourney equivalent) so the frontend can gate the JourneyItem UI correctly.

2. GET /api/geschichten/{id} now returns GeschichteView but GET /api/geschichten (list) still returns GeschichteSummary[] → then Geschichte[]

The diff shows the list endpoint return type changed from GeschichteSummary[] to Geschichte[]. This is an inconsistency: the detail view returns a new GeschichteView (with AuthorView, item list, etc.) while the list returns the full Geschichte entity (with AppUser author that may expose email). Additionally, the GeschichteSummary type is removed from the OpenAPI schema — if any frontend code was using GeschichteSummary, it now gets Geschichte which is a different shape. This is a breaking change to the list endpoint response contract. Confirm this is intentional and that all callers have been updated.


Requirements Gaps (Suggestions)

3. No GET /api/geschichten/{id}/items dedicated endpoint — items are only available via the full GeschichteView

The CRUD API adds POST, PATCH, DELETE, PUT (reorder) for items, but there is no standalone GET /{id}/items read endpoint. Items are only accessible via the full GeschichteView from GET /{id}. This is acceptable for the initial implementation, but if the UI needs to refresh just the item list after an edit (without re-fetching the whole Geschichte), it will over-fetch. Flag this as a potential future endpoint, or confirm the UI will always re-fetch the full view.

4. No documentId update path — once a JourneyItem has a linked document, it cannot be changed to link to a different document

The PATCH /{id}/items/{itemId} endpoint (via JourneyItemUpdateDTO) only allows updating the note field. There is no way to update the documentId on an existing item. To change the linked document, a user must delete the item and re-add it, losing position context. Is this intentional? If users may link the wrong document, a documentId update path would reduce friction. Worth capturing as a requirement for a follow-up issue.

5. No bulk-add path for multiple items

When a user wants to add several documents to a journey at once (e.g., selecting 5 documents in a batch), the current API requires 5 separate POST /{id}/items calls. This is fine for an MVP, but if the UI will support multi-select from the document list, a bulk append endpoint (POST /{id}/items/batch) would reduce request overhead. Not a blocker — just worth capturing in the backlog.

6. The 100-item capacity is a product decision that should be visible in the issue

MAX_ITEMS = 100 is hardcoded in JourneyItemService. The reasoning (preventing abuse, keeping the UI manageable) is not captured in the issue or in the code beyond the constant name. If a family archive eventually has a 150-document letter series that needs a journey, 100 may be too low. Recommend capturing the business rationale in issue #751 or the GLOSSARY so the decision is revisitable.


Positive Alignment

  • The three-way PATCH semantics (absent = no-op, null = clear, string = set) correctly model the user intent for the note field: a user can clear a note they typed in error, and an absent note field in a PATCH request doesn't accidentally clear an existing note.
  • The JOURNEY_AT_CAPACITY error code enables a good UX: the frontend can show a specific, actionable message rather than a generic 400.
## 📋 Elicit — Requirements Engineer & Business Analyst **Verdict: ⚠️ Approved with concerns** Reviewing this PR through the requirements and product lens: does the implemented API correctly and completely serve the use cases described in issue #751? The implementation is mostly well-aligned with the domain intent, but several requirements-level gaps warrant attention before the UI sprint begins. --- ### Blockers **1. The `GeschichteView` response omits `type` — the frontend cannot distinguish STORY from JOURNEY** The diff shows the `Geschichte` schema in `api.ts` now lacks the `type` field: ```diff - /** @enum {string} */ - type: "STORY" | "JOURNEY"; ``` The `GeschichteView` record (the new read model returned by `GET /api/geschichten/{id}`) appears to also omit type. Without `type` in the response, a frontend component rendering a journey detail page cannot confirm it is rendering a JOURNEY rather than a STORY — which means it cannot safely show or hide the item management UI. The `GeschichteView` must include `type` (or a boolean `isJourney` equivalent) so the frontend can gate the JourneyItem UI correctly. **2. `GET /api/geschichten/{id}` now returns `GeschichteView` but `GET /api/geschichten` (list) still returns `GeschichteSummary[]` → then `Geschichte[]`** The diff shows the list endpoint return type changed from `GeschichteSummary[]` to `Geschichte[]`. This is an inconsistency: the detail view returns a new `GeschichteView` (with `AuthorView`, item list, etc.) while the list returns the full `Geschichte` entity (with `AppUser` author that may expose email). Additionally, the `GeschichteSummary` type is removed from the OpenAPI schema — if any frontend code was using `GeschichteSummary`, it now gets `Geschichte` which is a different shape. This is a breaking change to the list endpoint response contract. Confirm this is intentional and that all callers have been updated. --- ### Requirements Gaps (Suggestions) **3. No `GET /api/geschichten/{id}/items` dedicated endpoint — items are only available via the full `GeschichteView`** The CRUD API adds `POST`, `PATCH`, `DELETE`, `PUT` (reorder) for items, but there is no standalone `GET /{id}/items` read endpoint. Items are only accessible via the full `GeschichteView` from `GET /{id}`. This is acceptable for the initial implementation, but if the UI needs to refresh just the item list after an edit (without re-fetching the whole Geschichte), it will over-fetch. Flag this as a potential future endpoint, or confirm the UI will always re-fetch the full view. **4. No `documentId` update path — once a JourneyItem has a linked document, it cannot be changed to link to a different document** The `PATCH /{id}/items/{itemId}` endpoint (via `JourneyItemUpdateDTO`) only allows updating the `note` field. There is no way to update the `documentId` on an existing item. To change the linked document, a user must delete the item and re-add it, losing position context. Is this intentional? If users may link the wrong document, a `documentId` update path would reduce friction. Worth capturing as a requirement for a follow-up issue. **5. No bulk-add path for multiple items** When a user wants to add several documents to a journey at once (e.g., selecting 5 documents in a batch), the current API requires 5 separate `POST /{id}/items` calls. This is fine for an MVP, but if the UI will support multi-select from the document list, a bulk append endpoint (`POST /{id}/items/batch`) would reduce request overhead. Not a blocker — just worth capturing in the backlog. **6. The 100-item capacity is a product decision that should be visible in the issue** `MAX_ITEMS = 100` is hardcoded in `JourneyItemService`. The reasoning (preventing abuse, keeping the UI manageable) is not captured in the issue or in the code beyond the constant name. If a family archive eventually has a 150-document letter series that needs a journey, 100 may be too low. Recommend capturing the business rationale in issue #751 or the GLOSSARY so the decision is revisitable. --- ### Positive Alignment - The three-way PATCH semantics (absent = no-op, `null` = clear, string = set) correctly model the user intent for the note field: a user can clear a note they typed in error, and an absent note field in a PATCH request doesn't accidentally clear an existing note. - The `JOURNEY_AT_CAPACITY` error code enables a good UX: the frontend can show a specific, actionable message rather than a generic 400.
marcel added 2 commits 2026-06-08 21:39:21 +02:00
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>
Author
Owner

Phase 4 complete — two review concerns addressed:

84b47f18 fix(geschichte): move DocumentSummary to journeyitem sub-package

  • Moved DocumentSummary.java from org.raddatz.familienarchiv.geschichte into org.raddatz.familienarchiv.geschichte.journeyitem where it belongs (it is only used by JourneyItem domain).
  • Removed now-redundant same-package imports from JourneyItemView and JourneyItemService.
  • No other callers existed; compile clean (BUILD SUCCESS).

77cbbd34 test(journeyitem): verify findSummaryByIdInternal never called before JOURNEY-type guard

  • Expanded the Javadoc on DocumentService.findSummaryByIdInternal to explicitly document: (1) the method intentionally bypasses scope checks, (2) it is only safe after @RequirePermission(BLOG_WRITE) is enforced at the controller layer, and (3) in JourneyItemService.append() it is additionally guarded by the JOURNEY-type check that fires before the document lookup.
  • Added new test append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY to JourneyItemServiceTest — asserts that documentService has no interactions when the JOURNEY-type guard rejects a STORY-type Geschichte, proving the security layering holds.
Phase 4 complete — two review concerns addressed: **84b47f18** `fix(geschichte): move DocumentSummary to journeyitem sub-package` - Moved `DocumentSummary.java` from `org.raddatz.familienarchiv.geschichte` into `org.raddatz.familienarchiv.geschichte.journeyitem` where it belongs (it is only used by JourneyItem domain). - Removed now-redundant same-package imports from `JourneyItemView` and `JourneyItemService`. - No other callers existed; compile clean (BUILD SUCCESS). **77cbbd34** `test(journeyitem): verify findSummaryByIdInternal never called before JOURNEY-type guard` - Expanded the Javadoc on `DocumentService.findSummaryByIdInternal` to explicitly document: (1) the method intentionally bypasses scope checks, (2) it is only safe after `@RequirePermission(BLOG_WRITE)` is enforced at the controller layer, and (3) in `JourneyItemService.append()` it is additionally guarded by the JOURNEY-type check that fires before the document lookup. - Added new test `append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY` to `JourneyItemServiceTest` — asserts that `documentService` has no interactions when the JOURNEY-type guard rejects a STORY-type Geschichte, proving the security layering holds.
marcel merged commit 0780c09bb4 into feat/issue-750-lesereisen-data-model 2026-06-08 22:15:12 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#788