feat(timeline): GET /api/timeline assembly endpoint (#777) #826

Merged
marcel merged 9 commits from feat/issue-777-timeline-assembly into main 2026-06-13 17:42:55 +02:00
Owner

Summary

  • Adds GET /api/timeline — merges curated TimelineEvent rows, derived person life-events (Geburt/Tod/Heirat), and archive letters into a year-bucketed TimelineDTO
  • Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear — all AND-combined
  • WITHIN_BAND_ORDER: precision rank desc (DAY→MONTH→SEASON→YEAR→APPROX) → date asc → title alpha → id tiebreak
  • null date or UNKNOWN precision → undated bucket; RANGE events placed in start-year band only
  • Letters (LETTER kind) always pass the type filter; personId deduplicates sender+receiver
  • Generation filter delegates to new PersonService.getPersonsByGeneration(Integer)
  • Replaces TimelineEntryDTO with the full 13-field unified shape (kind, eventId, documentId, senderName, receiverName, linkedPersonIds, derivedType, …)

Test plan

  • TimelineServiceTest — 24/24 (3 WITHIN_BAND_ORDER unit tests + 17 assembly tests + fromYear>toYear validation)
  • TimelineControllerTest — 8/8 (401, 403, 200, param binding, bad type, fromYear>toYear, @Min(0), 404)
  • TimelineServiceIntegrationTest — 4/4 (findByGeneration × 3 + real-DB assembly against Testcontainers Postgres)
  • ArchitectureTest — 14/14 (includes new Rule 2 entry for timeline domain)
  • TypeScript types regenerated (TimelineDTO, TimelineYearDTO, TimelineEntryDTO with "EVENT"|"LETTER" kind union)
  • All 20 REQ-NNN rows marked Done in .specify/rtm.md

Closes #777

🤖 Generated with Claude Code

## Summary - Adds `GET /api/timeline` — merges curated `TimelineEvent` rows, derived person life-events (Geburt/Tod/Heirat), and archive letters into a year-bucketed `TimelineDTO` - Five optional query params: `personId`, `generation` (`@Min(0)`), `type` (EventType enum), `fromYear`, `toYear` — all AND-combined - `WITHIN_BAND_ORDER`: precision rank desc (DAY→MONTH→SEASON→YEAR→APPROX) → date asc → title alpha → id tiebreak - null date or UNKNOWN precision → undated bucket; RANGE events placed in start-year band only - Letters (LETTER kind) always pass the type filter; personId deduplicates sender+receiver - Generation filter delegates to new `PersonService.getPersonsByGeneration(Integer)` - Replaces `TimelineEntryDTO` with the full 13-field unified shape (kind, eventId, documentId, senderName, receiverName, linkedPersonIds, derivedType, …) ## Test plan - [x] `TimelineServiceTest` — 24/24 (3 WITHIN_BAND_ORDER unit tests + 17 assembly tests + fromYear>toYear validation) - [x] `TimelineControllerTest` — 8/8 (401, 403, 200, param binding, bad type, fromYear>toYear, @Min(0), 404) - [x] `TimelineServiceIntegrationTest` — 4/4 (findByGeneration × 3 + real-DB assembly against Testcontainers Postgres) - [x] `ArchitectureTest` — 14/14 (includes new Rule 2 entry for timeline domain) - [x] TypeScript types regenerated (`TimelineDTO`, `TimelineYearDTO`, `TimelineEntryDTO` with `"EVENT"|"LETTER"` kind union) - [x] All 20 REQ-NNN rows marked Done in `.specify/rtm.md` Closes #777 🤖 Generated with [Claude Code](https://claude.ai/claude-code)
marcel added 7 commits 2026-06-13 16:53:15 +02:00
Replace the #776 DTO (primary/relatedPersonName + synthetic String id)
with the full #777 spec: kind, senderName, receiverName, eventId,
documentId, linkedPersonIds, title, eventDateEnd. Derived events now use
title=displayName, linkedPersonIds=[UUID...], eventId=null.

DerivedEventsAssemblyTest updated — all 16 tests pass.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonRepository.findByGeneration(Integer) — boxed to match nullable entity field.
DocumentRepository.findAllForTimeline() — Document.list entity-graph, single query.
Both services delegate with one-liner methods.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates TimelineService.assemble(TimelineFilter): merges curated events
(TimelineEventRepository), derived life-events (assembleDerivedEvents()),
and archive letters (DocumentService) into a year-bucketed TimelineDTO.
WITHIN_BAND_ORDER Comparator tested standalone before assembly tests.
ArchUnit Rule 2 entry for ..timeline.. domain added in same commit.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TimelineController exposes GET /api/timeline with @RequirePermission(READ_ALL)
and @Validated so @Min(0) on generation fires a 400. Delegates to
TimelineService.assemble(TimelineFilter). DomainException 404/400 propagate
via GlobalExceptionHandler (no extra mapping needed).

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies PersonRepository.findByGeneration handles match, no-match (empty
list not NPE), and null-generation persons (excluded). Also confirms
TimelineService.assemble() returns a persisted curated event in the
correct year band against real Postgres via Testcontainers.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TimelineDTO, TimelineYearDTO, TimelineEntryDTO with kind union
("EVENT"|"LETTER"), eventId, documentId, senderName, receiverName,
linkedPersonIds, derivedType fields.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(timeline): RTM, CLAUDE.md, and C4 updates for #777 assembly endpoint
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
1de314f49b
- Add 20 REQ-NNN rows for issue #777 (all Done) to .specify/rtm.md
- Update CLAUDE.md timeline package description with TimelineService/TimelineController
- Extend l3-backend-timeline.puml with TimelineService/TimelineController components
  and their edges to PersonService and DocumentService

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

📋 Requirements Engineer — PR Review

Verdict: Approved

Traceability check across all 20 REQ-NNN for issue #777:

REQ-ID Requirement Implemented Test RTM Done
REQ-001 GET /api/timeline 200 returns TimelineDTO TimelineController TimelineControllerTest#returns_200_with_dto
REQ-002 No filter → all curated + derived + letters TimelineService.assemble() TimelineServiceTest#test1
REQ-003 Curated events year-bucketed, undated in separate list bucketByYear() w/ TreeMap TimelineServiceTest#test2,#test3
REQ-004 Derived events bucketed by year (UNKNOWN → undated) assemble() routes derived events TimelineServiceTest#test4,#test5
REQ-005 Archive letters bucketed by their document date fetchDocuments() + bucketing TimelineServiceTest#test6,#test7
REQ-006 WITHIN_BAND_ORDER: precision-rank desc → date asc → title → id WITHIN_BAND_ORDER Comparator TimelineServiceTest#withinBandOrder_* (3 tests)
REQ-007 TreeMap → ascending year order in years list bucketByYear() uses TreeMap<>() TimelineServiceTest#test3
REQ-008 ?type=PERSONAL filters curated; derived always pass passesTypeFilter() TimelineServiceTest#test8
REQ-009 ?fromYear= / ?toYear= inclusive range passesYearFilter() TimelineServiceTest#test9,#test10
REQ-010 ?personId= filters events/letters by linked person passesPersonFilter() / fetchDocuments(personId) TimelineServiceTest#test11
REQ-011 ?generation=0 means all persons, =1 means direct family members resolveGenerationPersonIds() TimelineServiceTest#test12
REQ-012 Filters compose (AND semantics) all passes*Filter() chained TimelineServiceTest#test13
REQ-013 TimelineEntryDTO fields: kind/precision/derived/senderName/receiverName always non-null @Schema(REQUIRED) on 5 fields TimelineServiceTest structural checks
REQ-014 Unauthenticated → 401 @RequirePermission(READ_ALL) + SecurityConfig TimelineControllerTest#returns_401_for_unauthenticated
REQ-015 No READ_ALL → 403 PermissionAspect TimelineControllerTest#returns_403_without_read_all
REQ-016 Negative generation → 400 @Min(0) + @Validated TimelineControllerTest#returns_400_on_negative_generation
REQ-017 Unknown type value → 400 Spring MVC binding TimelineControllerTest#returns_400_on_bad_type_value
REQ-018 fromYear > toYear → 400 VALIDATION_ERROR DomainException.badRequest() in assemble() TimelineControllerTest#returns_400_when_fromYear_after_toYear
REQ-019 Unknown personId → 404 PERSON_NOT_FOUND personService.getById() throws notFound TimelineControllerTest#returns_404_for_unknown_person
REQ-020 No PII in logs, aggregate counts only single log.debug("assembled … events, {} letters") log-audit in service

All 20 REQ-NNN implemented, tested, and RTM rows marked Done.

One spec gap flagged (non-blocking question):
REQ-009 says "return only items within the inclusive year range" but doesn't address undated items. The implementation (passesYearFilter) returns true for any entry without an eventDate, meaning undated items always appear even when fromYear/toYear are set. This is likely intentional (undated items have no year to filter on), but it isn't captured in any requirement. If this is deliberate behavior, it should be added as a clarifying note to REQ-009. Not a blocker — but the next feature consuming this filter may assume otherwise.

### 📋 Requirements Engineer — PR Review **Verdict: ✅ Approved** Traceability check across all 20 REQ-NNN for issue #777: | REQ-ID | Requirement | Implemented | Test | RTM Done | |--------|-------------|-------------|------|----------| | REQ-001 | GET /api/timeline 200 returns TimelineDTO | ✅ `TimelineController` | `TimelineControllerTest#returns_200_with_dto` | ✅ | | REQ-002 | No filter → all curated + derived + letters | ✅ `TimelineService.assemble()` | `TimelineServiceTest#test1` | ✅ | | REQ-003 | Curated events year-bucketed, undated in separate list | ✅ `bucketByYear()` w/ TreeMap | `TimelineServiceTest#test2,#test3` | ✅ | | REQ-004 | Derived events bucketed by year (UNKNOWN → undated) | ✅ `assemble()` routes derived events | `TimelineServiceTest#test4,#test5` | ✅ | | REQ-005 | Archive letters bucketed by their document date | ✅ `fetchDocuments()` + bucketing | `TimelineServiceTest#test6,#test7` | ✅ | | REQ-006 | WITHIN_BAND_ORDER: precision-rank desc → date asc → title → id | ✅ `WITHIN_BAND_ORDER` Comparator | `TimelineServiceTest#withinBandOrder_*` (3 tests) | ✅ | | REQ-007 | TreeMap → ascending year order in `years` list | ✅ `bucketByYear()` uses `TreeMap<>()` | `TimelineServiceTest#test3` | ✅ | | REQ-008 | `?type=PERSONAL` filters curated; derived always pass | ✅ `passesTypeFilter()` | `TimelineServiceTest#test8` | ✅ | | REQ-009 | `?fromYear=` / `?toYear=` inclusive range | ✅ `passesYearFilter()` | `TimelineServiceTest#test9,#test10` | ✅ | | REQ-010 | `?personId=` filters events/letters by linked person | ✅ `passesPersonFilter()` / `fetchDocuments(personId)` | `TimelineServiceTest#test11` | ✅ | | REQ-011 | `?generation=0` means all persons, `=1` means direct family members | ✅ `resolveGenerationPersonIds()` | `TimelineServiceTest#test12` | ✅ | | REQ-012 | Filters compose (AND semantics) | ✅ all `passes*Filter()` chained | `TimelineServiceTest#test13` | ✅ | | REQ-013 | `TimelineEntryDTO` fields: kind/precision/derived/senderName/receiverName always non-null | ✅ `@Schema(REQUIRED)` on 5 fields | `TimelineServiceTest` structural checks | ✅ | | REQ-014 | Unauthenticated → 401 | ✅ `@RequirePermission(READ_ALL)` + SecurityConfig | `TimelineControllerTest#returns_401_for_unauthenticated` | ✅ | | REQ-015 | No READ_ALL → 403 | ✅ `PermissionAspect` | `TimelineControllerTest#returns_403_without_read_all` | ✅ | | REQ-016 | Negative `generation` → 400 | ✅ `@Min(0)` + `@Validated` | `TimelineControllerTest#returns_400_on_negative_generation` | ✅ | | REQ-017 | Unknown `type` value → 400 | ✅ Spring MVC binding | `TimelineControllerTest#returns_400_on_bad_type_value` | ✅ | | REQ-018 | fromYear > toYear → 400 VALIDATION_ERROR | ✅ `DomainException.badRequest()` in `assemble()` | `TimelineControllerTest#returns_400_when_fromYear_after_toYear` | ✅ | | REQ-019 | Unknown personId → 404 PERSON_NOT_FOUND | ✅ `personService.getById()` throws notFound | `TimelineControllerTest#returns_404_for_unknown_person` | ✅ | | REQ-020 | No PII in logs, aggregate counts only | ✅ single `log.debug("assembled … events, {} letters")` | log-audit in service | ✅ | **All 20 REQ-NNN implemented, tested, and RTM rows marked Done.** ✅ **One spec gap flagged (non-blocking question):** REQ-009 says "return only items within the inclusive year range" but doesn't address undated items. The implementation (`passesYearFilter`) returns `true` for any entry without an `eventDate`, meaning undated items always appear even when `fromYear`/`toYear` are set. This is likely intentional (undated items have no year to filter on), but it isn't captured in any requirement. If this is deliberate behavior, it should be added as a clarifying note to REQ-009. Not a blocker — but the next feature consuming this filter may assume otherwise.
Author
Owner

👨‍💻 Developer (Felix Brandt) — PR Review

Verdict: 🚫 Changes Requested

The overall structure is clean — layering is correct, no cross-domain repo access, @Schema(REQUIRED) is on the right fields, generate:api was run. One production bug must be fixed before merge.


Blockers

TimelineService.assemble() will throw LazyInitializationException in production

TimelineService.assemble() has no @Transactional annotation. Spring Data's SimpleJpaRepository.findAll() opens and closes its own @Transactional(readOnly=true) transaction. Once it returns, the Hibernate session closes and the returned TimelineEvent entities become detached. The method then accesses:

  • ev.getPersons() in mapEvent() and passesPersonFilter()@ManyToMany(fetch = FetchType.LAZY) on a detached entity → LazyInitializationException
  • doc.getReceivers() in passesLetterGenerationFilter() and resolveReceiverName() — also lazy, same problem

This will blow up in production for any request that touches events with linked persons or letters with receivers. With spring.jpa.open-in-view=false there is no session-per-request safety net.

Fix: add @Transactional(readOnly=true) to assemble():

@Transactional(readOnly=true)
public TimelineDTO assemble(TimelineFilter filter) { ... }

This keeps the Hibernate session open across all three sub-queries (events, derived events, documents) so lazy collections can be initialized as needed. The @BatchSize(size=50) annotations on both collections will then work correctly, batching the person/document loads efficiently.

The unit tests mask this bug because they mock eventRepository.findAll() and return hand-crafted TimelineEntryDTO objects (no Hibernate proxy involved). The integration test likely runs inside a @DataJpaTest-provided transaction, which also masks the issue.


Everything else looks clean

  • Constitution §1.2: controller calls only TimelineService, no repository access
  • Constitution §1.3: TimelineService uses own-domain TimelineEventRepository directly; cross-domain access via PersonService and DocumentService services only — no RelationshipService dependency
  • Constitution §3.5: @Schema(requiredMode = REQUIRED) on kind, precision, derived, senderName, receiverName in TimelineEntryDTO; on years + undated in TimelineDTO; on year + entries in TimelineYearDTO
  • Constitution §4.1: generate:api was run — TypeScript types in api.ts match the new DTO shapes
  • No new ErrorCodeVALIDATION_ERROR and PERSON_NOT_FOUND already existed
  • @Validated on TimelineController — required for @Min(0) on generation to fire
  • TimelineFilter as a value record — clean, no mutable state
  • WITHIN_BAND_ORDER as a named static final constant — readable and testable
  • TreeMap<>() in bucketByYear() — ascending year order without an extra sort
  • LinkedHashMap in fetchDocuments(personId) — clean dedup of sender+receiver documents without double-counting

Suggestion (non-blocking)

The CLAUDE.md §Services section says "Read methods are not annotated (default non-transactional is fine)." That guidance covers simple reads that return primitives or eagerly-loaded entities. assemble() is an orchestrator that must access lazy collections — it falls under the ADR-036 / constitution §1.6 pattern, not the "simple read" convention. It would be worth adding a note alongside the fix: // @Transactional needed: accesses lazy TimelineEvent.persons and Document.receivers.

### 👨‍💻 Developer (Felix Brandt) — PR Review **Verdict: 🚫 Changes Requested** The overall structure is clean — layering is correct, no cross-domain repo access, `@Schema(REQUIRED)` is on the right fields, `generate:api` was run. One production bug must be fixed before merge. --- ### Blockers **`TimelineService.assemble()` will throw `LazyInitializationException` in production** `TimelineService.assemble()` has no `@Transactional` annotation. Spring Data's `SimpleJpaRepository.findAll()` opens and closes its own `@Transactional(readOnly=true)` transaction. Once it returns, the Hibernate session closes and the returned `TimelineEvent` entities become detached. The method then accesses: - `ev.getPersons()` in `mapEvent()` and `passesPersonFilter()` — `@ManyToMany(fetch = FetchType.LAZY)` on a detached entity → `LazyInitializationException` - `doc.getReceivers()` in `passesLetterGenerationFilter()` and `resolveReceiverName()` — also lazy, same problem This will blow up in production for any request that touches events with linked persons or letters with receivers. With `spring.jpa.open-in-view=false` there is no session-per-request safety net. **Fix:** add `@Transactional(readOnly=true)` to `assemble()`: ```java @Transactional(readOnly=true) public TimelineDTO assemble(TimelineFilter filter) { ... } ``` This keeps the Hibernate session open across all three sub-queries (events, derived events, documents) so lazy collections can be initialized as needed. The `@BatchSize(size=50)` annotations on both collections will then work correctly, batching the person/document loads efficiently. **The unit tests mask this bug** because they mock `eventRepository.findAll()` and return hand-crafted `TimelineEntryDTO` objects (no Hibernate proxy involved). The integration test likely runs inside a `@DataJpaTest`-provided transaction, which also masks the issue. --- ### Everything else looks clean - ✅ Constitution §1.2: controller calls only `TimelineService`, no repository access - ✅ Constitution §1.3: `TimelineService` uses own-domain `TimelineEventRepository` directly; cross-domain access via `PersonService` and `DocumentService` services only — no `RelationshipService` dependency - ✅ Constitution §3.5: `@Schema(requiredMode = REQUIRED)` on `kind`, `precision`, `derived`, `senderName`, `receiverName` in `TimelineEntryDTO`; on `years` + `undated` in `TimelineDTO`; on `year` + `entries` in `TimelineYearDTO` - ✅ Constitution §4.1: `generate:api` was run — TypeScript types in `api.ts` match the new DTO shapes - ✅ No new `ErrorCode` — `VALIDATION_ERROR` and `PERSON_NOT_FOUND` already existed - ✅ `@Validated` on `TimelineController` — required for `@Min(0)` on `generation` to fire - ✅ `TimelineFilter` as a value record — clean, no mutable state - ✅ `WITHIN_BAND_ORDER` as a named `static final` constant — readable and testable - ✅ `TreeMap<>()` in `bucketByYear()` — ascending year order without an extra sort - ✅ `LinkedHashMap` in `fetchDocuments(personId)` — clean dedup of sender+receiver documents without double-counting --- ### Suggestion (non-blocking) The CLAUDE.md §Services section says "Read methods are not annotated (default non-transactional is fine)." That guidance covers simple reads that return primitives or eagerly-loaded entities. `assemble()` is an orchestrator that must access lazy collections — it falls under the ADR-036 / constitution §1.6 pattern, not the "simple read" convention. It would be worth adding a note alongside the fix: `// @Transactional needed: accesses lazy TimelineEvent.persons and Document.receivers`.
Author
Owner

🧪 Tester (Sara Holt) — PR Review

Verdict: ⚠️ Approved with Concerns

The test suite is genuinely well-structured — three clear levels, named helpers, all edge cases from the spec covered. One concern: the test suite doesn't catch a production bug that will surface at runtime.


Test Coverage Assessment

TimelineControllerTest (8 tests) — Excellent

  • Auth: 401 (unauthenticated), 403 (no READ_ALL) — correct @WebMvcTest + @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) setup matches the pattern established for other controller tests
  • Happy path: 200 delegates to TimelineService with all 5 params bound correctly
  • Input validation: bad type string → 400, fromYear > toYear → 400, negative generation → 400 (@Min(0) via @Validated)
  • Business errors: unknown personId → 404

All 8 tests map 1:1 to the spec's Unwanted-behavior requirements.

TimelineServiceTest (19+ unit tests) — Excellent

  • Three dedicated WITHIN_BAND_ORDER tests (precision rank, date order, tiebreak) — exactly right, the comparator is complex enough to deserve its own suite
  • Assembly tests cover: no filter, curated-only, derived-only, letters-only, year filter, personId filter, generation filter, filter composition
  • docWithDate() / event() / noFilters() factory helpers keep tests readable without hiding intent
  • fromYear > toYear validation tested at both service level and controller level

TimelineServiceIntegrationTest (4 tests, per PR description) — ⚠️ Concern

The PR claims 4 tests including "real-DB assembly against Testcontainers Postgres." However:

@DataJpaTest annotated test classes are themselves @Transactional by default — each test method runs inside a transaction that rolls back at the end. This means when TimelineService.assemble() is called inside the test, the outer transaction is still active, the Hibernate session is alive, and ev.getPersons() (which is FetchType.LAZY) can be initialized without error.

In production, there is no outer transaction. TimelineController.getTimeline() has no @Transactional, so assemble() runs without a session — and ev.getPersons() on a detached entity will throw LazyInitializationException.

The integration test gives false confidence. It passes today, but the behavior it exercises (lazy loading working within a @DataJpaTest transaction) is not representative of production behavior.


Suggested additional test (to add as part of the fix)

Once @Transactional(readOnly=true) is added to assemble(), the existing integration test becomes valid. Additionally, consider:

// In TimelineServiceIntegrationTest
@Test
void assembly_with_linked_persons_does_not_throw_lazy_initialization_exception() {
    Person p = personRepo.save(Person.builder().displayName("Anna Müller").build());
    TimelineEvent ev = eventRepo.save(TimelineEvent.builder()
        .title("WW1")
        .type(EventType.HISTORICAL)
        .eventDate(LocalDate.of(1914, 7, 28))
        .precision(DatePrecision.DAY)
        .createdBy("test").updatedBy("test")
        .persons(Set.of(p))
        .build());
    
    // If assemble() lacks @Transactional, this throws LazyInitializationException
    assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
}

This test must be run without @Transactional on the test method itself (or wrapped in a TransactionTemplate to execute the actual assertion outside any transaction), otherwise it won't catch the bug.


Non-blocking notes

  • The TimelineEntryDTO record uses positional constructors in test helpers (new TimelineEntryDTO(Kind.LETTER, ...)) — this is acceptable for test-only helpers but fragile if record fields are reordered. Consider named factory methods if the DTO stabilizes with 13 fields.
  • Test naming convention test1, test2 ... is consistent with the project's existing integration test style.
### 🧪 Tester (Sara Holt) — PR Review **Verdict: ⚠️ Approved with Concerns** The test suite is genuinely well-structured — three clear levels, named helpers, all edge cases from the spec covered. One concern: the test suite doesn't catch a production bug that will surface at runtime. --- ### Test Coverage Assessment **`TimelineControllerTest` (8 tests) — ✅ Excellent** - Auth: 401 (unauthenticated), 403 (no READ_ALL) — correct `@WebMvcTest` + `@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})` setup matches the pattern established for other controller tests - Happy path: 200 delegates to `TimelineService` with all 5 params bound correctly - Input validation: bad `type` string → 400, fromYear > toYear → 400, negative `generation` → 400 (`@Min(0)` via `@Validated`) - Business errors: unknown `personId` → 404 All 8 tests map 1:1 to the spec's Unwanted-behavior requirements. ✅ **`TimelineServiceTest` (19+ unit tests) — ✅ Excellent** - Three dedicated `WITHIN_BAND_ORDER` tests (precision rank, date order, tiebreak) — exactly right, the comparator is complex enough to deserve its own suite - Assembly tests cover: no filter, curated-only, derived-only, letters-only, year filter, personId filter, generation filter, filter composition - `docWithDate()` / `event()` / `noFilters()` factory helpers keep tests readable without hiding intent - fromYear > toYear validation tested at both service level and controller level ✅ **`TimelineServiceIntegrationTest` (4 tests, per PR description) — ⚠️ Concern** The PR claims 4 tests including "real-DB assembly against Testcontainers Postgres." However: `@DataJpaTest` annotated test classes are **themselves `@Transactional` by default** — each test method runs inside a transaction that rolls back at the end. This means when `TimelineService.assemble()` is called inside the test, the outer transaction is still active, the Hibernate session is alive, and `ev.getPersons()` (which is `FetchType.LAZY`) can be initialized without error. **In production, there is no outer transaction.** `TimelineController.getTimeline()` has no `@Transactional`, so `assemble()` runs without a session — and `ev.getPersons()` on a detached entity will throw `LazyInitializationException`. **The integration test gives false confidence.** It passes today, but the behavior it exercises (lazy loading working within a `@DataJpaTest` transaction) is not representative of production behavior. --- ### Suggested additional test (to add as part of the fix) Once `@Transactional(readOnly=true)` is added to `assemble()`, the existing integration test becomes valid. Additionally, consider: ```java // In TimelineServiceIntegrationTest @Test void assembly_with_linked_persons_does_not_throw_lazy_initialization_exception() { Person p = personRepo.save(Person.builder().displayName("Anna Müller").build()); TimelineEvent ev = eventRepo.save(TimelineEvent.builder() .title("WW1") .type(EventType.HISTORICAL) .eventDate(LocalDate.of(1914, 7, 28)) .precision(DatePrecision.DAY) .createdBy("test").updatedBy("test") .persons(Set.of(p)) .build()); // If assemble() lacks @Transactional, this throws LazyInitializationException assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null))); } ``` This test must be run **without** `@Transactional` on the test method itself (or wrapped in a `TransactionTemplate` to execute the actual assertion outside any transaction), otherwise it won't catch the bug. --- ### Non-blocking notes - The `TimelineEntryDTO` record uses positional constructors in test helpers (`new TimelineEntryDTO(Kind.LETTER, ...)`) — this is acceptable for test-only helpers but fragile if record fields are reordered. Consider named factory methods if the DTO stabilizes with 13 fields. - Test naming convention `test1`, `test2` ... is consistent with the project's existing integration test style. ✅
Author
Owner

🔐 Security (Nora "NullX" Steiner) — PR Review

Verdict: Approved

Read-only endpoint with no file handling, no new data flows, no AI invocation. Standard auth/authz model applied correctly.

# Item Status Note
1 All mutating endpoints have Unwanted-behavior EARS clause PASS No mutating endpoints in this PR
2 Every mutating endpoint names @RequirePermission(Permission.X) PASS No mutating endpoints
3 Audit fields server-set, not request-bound PASS Read-only — no audit fields touched
4 IDOR surface addressed PASS personId is a filter parameter on data the caller already has READ_ALL over; no per-resource ownership boundary to enforce
5 Untrusted text via default escaping only PASS No frontend component in this PR
6 File upload: content-type, size, magic-byte validation PASS No file upload
7 No entity internals leaked in response PASS TimelineService assembles TimelineEntryDTO records before the data reaches the controller — entities never cross the boundary. senderName / receiverName are display names, not email/password fields
8 Optimistic lock conflicts surface as 409, not 500 PASS Read-only — no locking needed
9 Threat model covers STRIDE for new data flows PASS Spec §Security considerations covers Spoofing (READ_ALL), Information Disclosure (DTO projection), Denial of Service (global findAll() noted, deferred to future pagination REQ)
10 AI agent invoked — ASTRIDE extensions PASS No AI invoked
11 No secrets in repo/config/logs PASS
12 Logging PII-free PASS Single log.debug("assembled {} events, {} letters") — only aggregate counts
13 New runtime dependency has ADR + clean audit PASS No new dependency

Additional checks:

  • @RequirePermission(Permission.READ_ALL) is on the method (not class) — correct for AOP interception via PermissionAspect.
  • @Validated on TimelineController class — required for Bean Validation annotations (@Min(0)) to fire. Without it, negative generation silently passes.
  • UUID personId param: Spring auto-rejects non-UUID strings with a 400 before reaching the service.
  • The global eventRepository.findAll() path: for a family archive with hundreds (not millions) of events, this is acceptable. The spec explicitly deferred pagination.
  • 401/403 tested in TimelineControllerTest — both auth failure modes are verified.
### 🔐 Security (Nora "NullX" Steiner) — PR Review **Verdict: ✅ Approved** Read-only endpoint with no file handling, no new data flows, no AI invocation. Standard auth/authz model applied correctly. | # | Item | Status | Note | |---|------|--------|------| | 1 | All mutating endpoints have Unwanted-behavior EARS clause | PASS | No mutating endpoints in this PR | | 2 | Every mutating endpoint names `@RequirePermission(Permission.X)` | PASS | No mutating endpoints | | 3 | Audit fields server-set, not request-bound | PASS | Read-only — no audit fields touched | | 4 | IDOR surface addressed | PASS | `personId` is a filter parameter on data the caller already has READ_ALL over; no per-resource ownership boundary to enforce | | 5 | Untrusted text via default escaping only | PASS | No frontend component in this PR | | 6 | File upload: content-type, size, magic-byte validation | PASS | No file upload | | 7 | No entity internals leaked in response | PASS | `TimelineService` assembles `TimelineEntryDTO` records before the data reaches the controller — entities never cross the boundary. `senderName` / `receiverName` are display names, not email/password fields | | 8 | Optimistic lock conflicts surface as 409, not 500 | PASS | Read-only — no locking needed | | 9 | Threat model covers STRIDE for new data flows | PASS | Spec §Security considerations covers Spoofing (READ_ALL), Information Disclosure (DTO projection), Denial of Service (global `findAll()` noted, deferred to future pagination REQ) | | 10 | AI agent invoked — ASTRIDE extensions | PASS | No AI invoked | | 11 | No secrets in repo/config/logs | PASS | | | 12 | Logging PII-free | PASS | Single `log.debug("assembled {} events, {} letters")` — only aggregate counts | | 13 | New runtime dependency has ADR + clean audit | PASS | No new dependency | **Additional checks:** - `@RequirePermission(Permission.READ_ALL)` is on the **method** (not class) — correct for AOP interception via `PermissionAspect`. ✅ - `@Validated` on `TimelineController` class — required for Bean Validation annotations (`@Min(0)`) to fire. Without it, negative `generation` silently passes. ✅ - UUID `personId` param: Spring auto-rejects non-UUID strings with a 400 before reaching the service. ✅ - The global `eventRepository.findAll()` path: for a family archive with hundreds (not millions) of events, this is acceptable. The spec explicitly deferred pagination. ✅ - 401/403 tested in `TimelineControllerTest` — both auth failure modes are verified. ✅
Author
Owner

🚀 DevOps (Tobias Wendt) — PR Review

Verdict: Approved

Read-only endpoint with no infrastructure changes. All deployment concerns are clean.

# Item Status Note
1 Rollback strategy for any DB migration PASS No Flyway migration — GET /api/timeline reads existing data
2 Flyway migration number verified against disk PASS No migration
3 New config values as documented env vars in .env.example PASS No new configuration
4 New CI steps avoid actions/upload-artifact@v4+ PASS No CI changes
5 Self-testing CI guards PASS No new CI guards
6 Management port (8081) / app port (8080) separation intact PASS No port changes
7 Dependencies pinned, npm audit + Semgrep green PASS No new dependencies
8 New external service has healthcheck + idempotent bootstrap PASS No new services
9 New metrics/logs/traces routed through existing observability stack PASS Spring's http_server_requests_seconds Micrometer metric automatically covers GET /api/timeline — no explicit wiring needed
10 Logging PII-free and structured PASS Single aggregate debug log, SLF4J structured output via existing Logback JSON config
11 Backwards-compatible across rolling deploy PASS New endpoint; nothing removed; safe to deploy in-place
12 No secrets committed PASS

Notes:

  • The endpoint adds a new /api/timeline route. The existing CRUD endpoints moved to /api/timeline/events (as shown in the updated C4 diagram description). Verify the TimelineEventController's @RequestMapping was actually changed in this branch — if it was already at /api/timeline/events and only the C4 diagram label was stale, no concern. If it was changed, this is a breaking change for any existing API consumers (the Chronik/Aktivitäten page, API test files). (the frontend api.ts shows the CRUD routes under /api/timeline/events/{id} — confirmed correct)
  • No Compose file changes needed — the existing service definition covers the new endpoint transparently.
### 🚀 DevOps (Tobias Wendt) — PR Review **Verdict: ✅ Approved** Read-only endpoint with no infrastructure changes. All deployment concerns are clean. | # | Item | Status | Note | |---|------|--------|------| | 1 | Rollback strategy for any DB migration | PASS | No Flyway migration — `GET /api/timeline` reads existing data | | 2 | Flyway migration number verified against disk | PASS | No migration | | 3 | New config values as documented env vars in `.env.example` | PASS | No new configuration | | 4 | New CI steps avoid `actions/upload-artifact@v4+` | PASS | No CI changes | | 5 | Self-testing CI guards | PASS | No new CI guards | | 6 | Management port (8081) / app port (8080) separation intact | PASS | No port changes | | 7 | Dependencies pinned, `npm audit` + Semgrep green | PASS | No new dependencies | | 8 | New external service has healthcheck + idempotent bootstrap | PASS | No new services | | 9 | New metrics/logs/traces routed through existing observability stack | PASS | Spring's `http_server_requests_seconds` Micrometer metric automatically covers `GET /api/timeline` — no explicit wiring needed | | 10 | Logging PII-free and structured | PASS | Single aggregate debug log, SLF4J structured output via existing Logback JSON config | | 11 | Backwards-compatible across rolling deploy | PASS | New endpoint; nothing removed; safe to deploy in-place | | 12 | No secrets committed | PASS | | **Notes:** - The endpoint adds a new `/api/timeline` route. The existing CRUD endpoints moved to `/api/timeline/events` (as shown in the updated C4 diagram description). Verify the `TimelineEventController`'s `@RequestMapping` was actually changed in this branch — if it was already at `/api/timeline/events` and only the C4 diagram label was stale, no concern. If it was changed, this is a breaking change for any existing API consumers (the Chronik/Aktivitäten page, API test files). ✅ (the frontend `api.ts` shows the CRUD routes under `/api/timeline/events/{id}` — confirmed correct) - No Compose file changes needed — the existing service definition covers the new endpoint transparently.
Author
Owner

🎨 UI/UX (Leonie Voss) — PR Review

Verdict: Approved

This PR is backend + API types only. No Svelte components, routes, or i18n strings were changed. The frontend rendering (issue #6) and edit affordance (issue #7) are separate issues on the roadmap. I'm reviewing only whether the DTO contract is sound for those downstream issues.

# Item Status Note
1 Every interaction state described (loading, empty, error) N/A Backend PR — states are in the consumer issues
2 User-facing strings via Paraglide i18n PASS No strings in this PR
3 Reuses established components/tokens N/A Backend PR
4 Responsive behavior specified per device split N/A Backend PR
5 Error states map to getErrorMessage(code) PASS No new ErrorCode in this PR
6 Keyboard-reachable, screen-reader labeled N/A Backend PR
7 Acceptance criteria measurable N/A Backend PR
8 E2E Playwright scenario defined N/A Deferred to issue #6 per spec
9 Destructive actions have confirmation affordance N/A Read-only
10 Content via default escaping, no {@html} PASS No frontend component
11 Live navigation respected N/A Backend PR
12 Dark-mode / semantic tokens used N/A Backend PR

DTO contract review for downstream issues #6 and #7:

The TimelineEntryDTO shape is well-designed for frontend consumption:

  • kind: "EVENT" | "LETTER" — clean discriminator for rendering branching
  • derived: boolean + eventId: UUID | null — edit affordance: if (!entry.derived && entry.eventId) → show edit link. The Javadoc makes this explicit
  • senderName: string (non-null, "" for unknown) + receiverName: string (non-null, "" for unknown) — the frontend can do senderName || m.person_unknown() without null-checking
  • precision: DatePrecision as a raw enum value — lets dateLabel.ts (issue #778) do its own formatting rather than baking format logic into the backend
  • linkedPersonIds: UUID[] for EVENT kind — available for future hover cards or person-chip links

One note for issue #6: The Javadoc comment says senderName "" → frontend renders 'Unbekannt'. When issue #6 implements this, use m.person_unknown() or the appropriate Paraglide key rather than hardcoding 'Unbekannt' (German-only). The DTO contract itself is correct — this is just a heads-up for the consumer.

### 🎨 UI/UX (Leonie Voss) — PR Review **Verdict: ✅ Approved** This PR is backend + API types only. No Svelte components, routes, or i18n strings were changed. The frontend rendering (issue #6) and edit affordance (issue #7) are separate issues on the roadmap. I'm reviewing only whether the DTO contract is sound for those downstream issues. | # | Item | Status | Note | |---|------|--------|------| | 1 | Every interaction state described (loading, empty, error) | N/A | Backend PR — states are in the consumer issues | | 2 | User-facing strings via Paraglide i18n | PASS | No strings in this PR | | 3 | Reuses established components/tokens | N/A | Backend PR | | 4 | Responsive behavior specified per device split | N/A | Backend PR | | 5 | Error states map to `getErrorMessage(code)` | PASS | No new `ErrorCode` in this PR | | 6 | Keyboard-reachable, screen-reader labeled | N/A | Backend PR | | 7 | Acceptance criteria measurable | N/A | Backend PR | | 8 | E2E Playwright scenario defined | N/A | Deferred to issue #6 per spec | | 9 | Destructive actions have confirmation affordance | N/A | Read-only | | 10 | Content via default escaping, no `{@html}` | PASS | No frontend component | | 11 | Live navigation respected | N/A | Backend PR | | 12 | Dark-mode / semantic tokens used | N/A | Backend PR | **DTO contract review for downstream issues #6 and #7:** The `TimelineEntryDTO` shape is well-designed for frontend consumption: - `kind: "EVENT" | "LETTER"` — clean discriminator for rendering branching ✅ - `derived: boolean` + `eventId: UUID | null` — edit affordance: `if (!entry.derived && entry.eventId)` → show edit link. The Javadoc makes this explicit ✅ - `senderName: string` (non-null, `""` for unknown) + `receiverName: string` (non-null, `""` for unknown) — the frontend can do `senderName || m.person_unknown()` without null-checking ✅ - `precision: DatePrecision` as a raw enum value — lets `dateLabel.ts` (issue #778) do its own formatting rather than baking format logic into the backend ✅ - `linkedPersonIds: UUID[]` for EVENT kind — available for future hover cards or person-chip links ✅ **One note for issue #6:** The Javadoc comment says `senderName "" → frontend renders 'Unbekannt'`. When issue #6 implements this, use `m.person_unknown()` or the appropriate Paraglide key rather than hardcoding `'Unbekannt'` (German-only). The DTO contract itself is correct — this is just a heads-up for the consumer.
Author
Owner

🏛️ Architect (Markus Keller) — PR Review

Verdict: 🚫 Changes Requested

The domain design, C4 diagram update, and cross-boundary patterns are all correct. One production-breaking violation of constitution §1.6 must be fixed before merge, and the CLAUDE.md domain model table needs updating.


Blockers

1. TimelineService.assemble() violates Constitution §1.6

Constitution §1.6: "Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction."

The key phrase is "inside the transaction." TimelineService.assemble() has no @Transactional annotation. It calls eventRepository.findAll() (which opens and closes its own @Transactional(readOnly=true) sub-transaction), then accesses ev.getPersons() — a @ManyToMany(fetch = FetchType.LAZY) collection — on detached entities. This is exactly the pattern §1.6 and ADR-036 were written to prevent.

The GeschichteQueryService is the reference implementation of the correct pattern: it's @Transactional and assembles views inside that transaction. The TimelineEventService.assembleDerivedEvents() (from the previous issue #776) was also correctly annotated @Transactional(readOnly=true).

Fix required: @Transactional(readOnly=true) on TimelineService.assemble().

Note that CLAUDE.md's "read methods are not annotated" convention covers simple reads on scalar fields or eagerly-loaded associations. It was never meant to exempt orchestrator methods that access lazy collections. The constitution overrides CLAUDE.md when they conflict.


2. CLAUDE.md TimelineEntryDTO domain model table entry is stale

The spec for issue #777 required updating the CLAUDE.md domain model table for TimelineEntryDTO. The current entry on the branch still reads:

id: String (UUID for curated, prefixed synthetic for derived: birth:, death:, marriage:); primaryPersonName + relatedPersonName for localized label composition in #6/#7

But the PR's actual TimelineEntryDTO has completely different fields: kind (EVENT/LETTER), senderName, receiverName, eventId: UUID, documentId: UUID, linkedPersonIds: List<UUID>, precision, derived, derivedType. Neither id: String nor primaryPersonName/relatedPersonName exist in the implemented record.

The CLAUDE.md entry needs to be updated to match the actual 13-field shape. This is a required doc task (spec §Doc tasks) and is important because the stale entry will confuse future LLM agents reading CLAUDE.md.


Everything else is architecturally sound

Domain package structure

  • TimelineService, TimelineController, TimelineFilter, TimelineDTO, TimelineYearDTO all correctly placed in timeline/ package
  • No new package introduced — timeline/ already existed and is in ArchitectureTest's allow-list

Layering

  • TimelineControllerTimelineService only (§1.2)
  • TimelineService uses TimelineEventRepository (own domain), TimelineEventService (own domain), PersonService (cross-domain via service), DocumentService (cross-domain via service) — no RelationshipService reference (§1.3)

C4 diagram

  • l3-backend-timeline.puml updated: new TimelineService and TimelineController components added; edges correctly show no RelationshipService dependency; TimelineEventController description updated to /api/timeline/events

ArchitectureTest (per PR description: "14/14 — includes new Rule 2 entry for timeline domain")

No new ADR required

  • The response shape is a DTO record (no lazy entity crosses the boundary — once @Transactional is added)
  • No new runtime dependency
  • No irreversible infrastructure change
### 🏛️ Architect (Markus Keller) — PR Review **Verdict: 🚫 Changes Requested** The domain design, C4 diagram update, and cross-boundary patterns are all correct. One production-breaking violation of constitution §1.6 must be fixed before merge, and the CLAUDE.md domain model table needs updating. --- ### Blockers **1. `TimelineService.assemble()` violates Constitution §1.6** Constitution §1.6: *"Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction."* The key phrase is "inside the transaction." `TimelineService.assemble()` has no `@Transactional` annotation. It calls `eventRepository.findAll()` (which opens and closes its own `@Transactional(readOnly=true)` sub-transaction), then accesses `ev.getPersons()` — a `@ManyToMany(fetch = FetchType.LAZY)` collection — on detached entities. This is exactly the pattern §1.6 and ADR-036 were written to prevent. The `GeschichteQueryService` is the reference implementation of the correct pattern: it's `@Transactional` and assembles views inside that transaction. The `TimelineEventService.assembleDerivedEvents()` (from the previous issue #776) was also correctly annotated `@Transactional(readOnly=true)`. **Fix required:** `@Transactional(readOnly=true)` on `TimelineService.assemble()`. Note that CLAUDE.md's "read methods are not annotated" convention covers simple reads on scalar fields or eagerly-loaded associations. It was never meant to exempt orchestrator methods that access lazy collections. The constitution overrides CLAUDE.md when they conflict. --- **2. CLAUDE.md `TimelineEntryDTO` domain model table entry is stale** The spec for issue #777 required updating the CLAUDE.md domain model table for `TimelineEntryDTO`. The current entry on the branch still reads: > `id: String` (UUID for curated, prefixed synthetic for derived: `birth:`, `death:`, `marriage:`); `primaryPersonName` + `relatedPersonName` for localized label composition in #6/#7 But the PR's actual `TimelineEntryDTO` has completely different fields: `kind` (EVENT/LETTER), `senderName`, `receiverName`, `eventId: UUID`, `documentId: UUID`, `linkedPersonIds: List<UUID>`, `precision`, `derived`, `derivedType`. Neither `id: String` nor `primaryPersonName`/`relatedPersonName` exist in the implemented record. The CLAUDE.md entry needs to be updated to match the actual 13-field shape. This is a required doc task (spec §Doc tasks) and is important because the stale entry will confuse future LLM agents reading CLAUDE.md. --- ### Everything else is architecturally sound **Domain package structure** ✅ - `TimelineService`, `TimelineController`, `TimelineFilter`, `TimelineDTO`, `TimelineYearDTO` all correctly placed in `timeline/` package - No new package introduced — `timeline/` already existed and is in `ArchitectureTest`'s allow-list **Layering** ✅ - `TimelineController` → `TimelineService` only (§1.2) - `TimelineService` uses `TimelineEventRepository` (own domain), `TimelineEventService` (own domain), `PersonService` (cross-domain via service), `DocumentService` (cross-domain via service) — no `RelationshipService` reference (§1.3) **C4 diagram** ✅ - `l3-backend-timeline.puml` updated: new `TimelineService` and `TimelineController` components added; edges correctly show no `RelationshipService` dependency; `TimelineEventController` description updated to `/api/timeline/events` **ArchitectureTest** ✅ (per PR description: "14/14 — includes new Rule 2 entry for `timeline` domain") **No new ADR required** ✅ - The response shape is a DTO record (no lazy entity crosses the boundary — once `@Transactional` is added) - No new runtime dependency - No irreversible infrastructure change
marcel added 2 commits 2026-06-13 17:24:31 +02:00
Without the annotation, Hibernate closes its sub-transaction after
eventRepository.findAll() returns, leaving TimelineEvent entities
detached. Accessing ev.getPersons() or doc.getReceivers() on those
detached entities throws LazyInitializationException in production
(constitution §1.6). @DataJpaTest and @Transactional test classes
masked the bug by keeping an outer session alive.

Fixes: @felix / @markus review blockers on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m25s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 5m27s
CI / fail2ban Regex (push) Successful in 53s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 6m30s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Successful in 6m15s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
1348255ae3
The previous entry referenced fields (id: String, primaryPersonName,
relatedPersonName) from an earlier design that was superseded during
spec review. Replace with the actual 13-field record shape implemented
in PR #826.

Fixes: @markus stale CLAUDE.md entry on PR #826

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

👨‍💻 Felix Brandt — PR update

Both blocker concerns from @felix and @markus are resolved. Two commits pushed to this branch.


Commit 1: fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble() (590b00d2)

Addresses: Developer blocker + Architect blocker 1 — LazyInitializationException in production.

  • Added @Transactional(readOnly = true) to TimelineService.assemble().
  • Added a Javadoc paragraph on the method explaining exactly why the annotation is required here (lazy TimelineEvent.persons and Document.receivers collections, constitution §1.6) — this directly addresses @felix's suggestion to document the deviation from the "read methods don't need @Transactional" CLAUDE.md convention.
  • Added TimelineServiceLazyLoadTest — a @SpringBootTest without class-level @Transactional, which commits a TimelineEvent with a linked Person via TransactionTemplate, then calls assemble() outside any transaction. This test was RED (LazyInitializationException at TimelineService.mapEvent:198) before the fix and is GREEN after. It addresses @sara's concern that the existing @DataJpaTest @Transactional class gave false confidence.
  • All 37 timeline tests pass: 8 controller + 24 service unit + 4 integration + 1 lazy-load.

Commit 2: docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md (1348255a)

Addresses: Architect blocker 2 — stale CLAUDE.md TimelineEntryDTO row.

Replaced the old entry (id: String, primaryPersonName, relatedPersonName) with the actual 13-field record shape: kind, precision, derived, senderName, receiverName, eventDate, eventDateEnd, title, type, eventId, documentId, linkedPersonIds, derivedType — including the edit-affordance contract.


Non-blocker / spec question from @req-eng (undated items + fromYear/toYear): The current behaviour (passesYearFilter returns true for null-date entries regardless of fromYear/toYear) is the intentional design — undated items have no year to filter on so they always appear. This was a deliberate decision (not captured as a REQ). Agreed it should be noted; I'll add a clarifying sentence to REQ-009 in issue #777 as a follow-up rather than holding the PR.

### 👨‍💻 Felix Brandt — PR update Both blocker concerns from @felix and @markus are resolved. Two commits pushed to this branch. --- **Commit 1: `fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble()`** (`590b00d2`) Addresses: Developer blocker + Architect blocker 1 — `LazyInitializationException` in production. - Added `@Transactional(readOnly = true)` to `TimelineService.assemble()`. - Added a Javadoc paragraph on the method explaining exactly why the annotation is required here (lazy `TimelineEvent.persons` and `Document.receivers` collections, constitution §1.6) — this directly addresses @felix's suggestion to document the deviation from the "read methods don't need `@Transactional`" CLAUDE.md convention. - Added `TimelineServiceLazyLoadTest` — a `@SpringBootTest` **without** class-level `@Transactional`, which commits a `TimelineEvent` with a linked `Person` via `TransactionTemplate`, then calls `assemble()` outside any transaction. This test was RED (LazyInitializationException at `TimelineService.mapEvent:198`) before the fix and is GREEN after. It addresses @sara's concern that the existing `@DataJpaTest @Transactional` class gave false confidence. - All 37 timeline tests pass: 8 controller + 24 service unit + 4 integration + 1 lazy-load. --- **Commit 2: `docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md`** (`1348255a`) Addresses: Architect blocker 2 — stale CLAUDE.md `TimelineEntryDTO` row. Replaced the old entry (`id: String`, `primaryPersonName`, `relatedPersonName`) with the actual 13-field record shape: `kind`, `precision`, `derived`, `senderName`, `receiverName`, `eventDate`, `eventDateEnd`, `title`, `type`, `eventId`, `documentId`, `linkedPersonIds`, `derivedType` — including the edit-affordance contract. --- **Non-blocker / spec question from @req-eng (undated items + fromYear/toYear):** The current behaviour (`passesYearFilter` returns `true` for null-date entries regardless of `fromYear`/`toYear`) is the intentional design — undated items have no year to filter on so they always appear. This was a deliberate decision (not captured as a REQ). Agreed it should be noted; I'll add a clarifying sentence to REQ-009 in issue #777 as a follow-up rather than holding the PR.
marcel merged commit 1348255ae3 into main 2026-06-13 17:42:55 +02:00
marcel deleted branch feat/issue-777-timeline-assembly 2026-06-13 17:42:55 +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#826