From 184fc9814a979b46dc8d953df6f70190ef5a6e6a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:03:00 +0200 Subject: [PATCH 1/9] refactor(timeline): adapt TimelineEntryDTO to unified #777 shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../raddatz/familienarchiv/timeline/Kind.java | 7 ++++ .../timeline/TimelineEntryDTO.java | 39 +++++++++++------- .../timeline/TimelineEventService.java | 40 ++++++++----------- .../timeline/DerivedEventsAssemblyTest.java | 17 ++++---- 4 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/Kind.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/Kind.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/Kind.java new file mode 100644 index 00000000..4ac07440 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/Kind.java @@ -0,0 +1,7 @@ +package org.raddatz.familienarchiv.timeline; + +/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */ +public enum Kind { + EVENT, + LETTER +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java index 9f729be9..44cf88ec 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java @@ -4,28 +4,39 @@ import io.swagger.v3.oas.annotations.media.Schema; import org.raddatz.familienarchiv.document.DatePrecision; import java.time.LocalDate; +import java.util.List; +import java.util.UUID; /** - * Unified DTO for timeline entries — covers both curated {@link TimelineEvent} rows - * ({@code derived=false}) and derived life-events assembled from Person/relationship data - * ({@code derived=true}). + * Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived + * life-events ({@link DerivedEventType}), and archive letters (Documents). * - *

The {@code id} field is typed {@code String}, not {@code UUID}, because derived events - * carry synthetic prefixed ids ({@code birth:{uuid}}, {@code death:{uuid}}, - * {@code marriage:{uuid}}) that are structurally non-UUID by construction. Any write endpoint - * must reject ids that do not parse as {@code UUID} — enforced and tested in issue #5. + *

Edit-affordance contract (for issue #7): {@code derived == true || eventId == null} + * means no edit link should be rendered by the frontend. * - *

Callers of {@code TimelineService.assembleDerivedEvents()} must independently enforce + *

Letter display fields: {@code senderName} — {@code ""} means unknown/unlinked + * correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for + * {@link Kind#LETTER} entries. + * + *

Type field: {@code null} for {@link Kind#LETTER} entries; frontend must not render + * an event-type badge for letters. + * + *

Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce * {@code READ_ALL} authorization before invoking that method (see ADR-043). */ public record TimelineEntryDTO( - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String id, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type, - LocalDate eventDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived, - DerivedEventType derivedType, - String primaryPersonName, - String relatedPersonName + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName, + LocalDate eventDate, + LocalDate eventDateEnd, + String title, + EventType type, + UUID eventId, + UUID documentId, + List linkedPersonIds, + DerivedEventType derivedType ) { } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java index 3a50d4bf..75803bb0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -263,14 +263,10 @@ public class TimelineEventService { return persons.stream() .filter(p -> p.getBirthDate() != null) .map(p -> new TimelineEntryDTO( - "birth:" + p.getId(), - EventType.PERSONAL, - p.getBirthDate(), - p.getBirthDatePrecision(), - true, - DerivedEventType.BIRTH, - p.getDisplayName(), - null)) + Kind.EVENT, p.getBirthDatePrecision(), true, "", "", + p.getBirthDate(), null, + p.getDisplayName(), EventType.PERSONAL, + null, null, List.of(p.getId()), DerivedEventType.BIRTH)) .toList(); } @@ -278,14 +274,10 @@ public class TimelineEventService { return persons.stream() .filter(p -> p.getDeathDate() != null) .map(p -> new TimelineEntryDTO( - "death:" + p.getId(), - EventType.PERSONAL, - p.getDeathDate(), - p.getDeathDatePrecision(), - true, - DerivedEventType.DEATH, - p.getDisplayName(), - null)) + Kind.EVENT, p.getDeathDatePrecision(), true, "", "", + p.getDeathDate(), null, + p.getDisplayName(), EventType.PERSONAL, + null, null, List.of(p.getId()), DerivedEventType.DEATH)) .toList(); } @@ -303,15 +295,15 @@ public class TimelineEventService { DatePrecision precision = r.getFromYear() != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN; + String title = r.getPerson().getDisplayName() + + " & " + r.getRelatedPerson().getDisplayName(); result.add(new TimelineEntryDTO( - "marriage:" + r.getId(), - EventType.PERSONAL, - eventDate, - precision, - true, - DerivedEventType.MARRIAGE, - r.getPerson().getDisplayName(), - r.getRelatedPerson().getDisplayName())); + Kind.EVENT, precision, true, "", "", + eventDate, null, + title, EventType.PERSONAL, + null, null, + List.of(r.getPerson().getId(), r.getRelatedPerson().getId()), + DerivedEventType.MARRIAGE)); } } return result; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java index 9566b1fd..ddef7bd6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java @@ -18,7 +18,6 @@ import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -108,7 +107,7 @@ class DerivedEventsAssemblyTest { assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH); assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12)); assertThat(event.precision()).isEqualTo(DatePrecision.DAY); - assertThat(event.primaryPersonName()).isEqualTo(anna.getDisplayName()); + assertThat(event.title()).isEqualTo(anna.getDisplayName()); } // --- REQ-003: null birthDate → no Geburt event --- @@ -162,7 +161,7 @@ class DerivedEventsAssemblyTest { assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH); assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4)); assertThat(event.precision()).isEqualTo(DatePrecision.DAY); - assertThat(event.primaryPersonName()).isEqualTo(hans.getDisplayName()); + assertThat(event.title()).isEqualTo(hans.getDisplayName()); } // --- REQ-002 + REQ-003 combined --- @@ -285,10 +284,10 @@ class DerivedEventsAssemblyTest { List result = service.assembleDerivedEvents(); assertThat(result).hasSize(1); - String id = result.get(0).id(); - assertThat(id).startsWith("birth:"); - assertThatThrownBy(() -> UUID.fromString(id)) - .isInstanceOf(IllegalArgumentException.class); + TimelineEntryDTO entry = result.get(0); + assertThat(entry.derived()).isTrue(); + assertThat(entry.eventId()).isNull(); + assertThat(entry.documentId()).isNull(); } // --- REQ-010: display names on Heirat --- @@ -307,8 +306,8 @@ class DerivedEventsAssemblyTest { assertThat(heiraten).hasSize(1); TimelineEntryDTO heirat = heiraten.get(0); - assertThat(heirat.primaryPersonName()).isNotNull().isNotBlank(); - assertThat(heirat.relatedPersonName()).isNotNull().isNotBlank(); + assertThat(heirat.title()).isNotNull().isNotBlank(); + assertThat(heirat.linkedPersonIds()).hasSize(2); } // --- REQ-007 note: assumption/documentation test --- -- 2.49.1 From de30f66a2da2e1bbd3469152a3f3be55d8f97111 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:06:03 +0200 Subject: [PATCH 2/9] feat(timeline): add PersonService.getPersonsByGeneration + DocumentService.getAllForTimeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../document/DocumentRepository.java | 5 +++++ .../document/DocumentService.java | 4 ++++ .../person/PersonRepository.java | 3 +++ .../familienarchiv/person/PersonService.java | 4 ++++ .../document/DocumentServiceTest.java | 13 ++++++++++++ .../person/PersonServiceTest.java | 21 +++++++++++++++++++ 6 files changed, 50 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index 72a9fd03..c33480f1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -56,6 +56,11 @@ public interface DocumentRepository extends JpaRepository, JpaSp // Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück) boolean existsByOriginalFilename(String originalFilename); + // Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded. + @EntityGraph("Document.list") + @Query("SELECT d FROM Document d") + List findAllForTimeline(); + // lazy – @BatchSize(50) fallback active; see ADR-022 @EntityGraph("Document.full") List findBySenderId(UUID senderId); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 61b578de..99e48f48 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -1051,6 +1051,10 @@ public class DocumentService { return documentRepository.findDocumentsWithoutVersions(); } + public List getAllForTimeline() { + return documentRepository.findAllForTimeline(); + } + public List getDocumentsBySender(UUID senderId) { return documentRepository.findBySenderId(senderId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 18c2e8a4..0b12270e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -242,4 +242,7 @@ public interface PersonRepository extends JpaRepository { ) """, nativeQuery = true) void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target); + + // Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows). + List findByGeneration(Integer generation); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index e7a9ea1f..f51d2d47 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -210,6 +210,10 @@ public class PersonService { return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); } + public List getPersonsByGeneration(Integer generation) { + return personRepository.findByGeneration(generation); + } + @Transactional public Person setFamilyMember(UUID personId, boolean familyMember) { Person person = getById(personId); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index eadad6ac..fb77f39e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -2943,4 +2943,17 @@ class DocumentServiceTest { assertThat(result.buckets()).isEmpty(); verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class)); } + + // --- getAllForTimeline --- + + @Test + void getAllForTimeline_delegates_bulk_fetch_to_repository() { + Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build(); + when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc)); + + List result = documentService.getAllForTimeline(); + + assertThat(result).containsExactly(doc); + verify(documentRepository).findAllForTimeline(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 161e359c..f253f0a0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -1105,4 +1105,25 @@ class PersonServiceTest { assertThat(result.direct()).hasSize(1); assertThat(result.partial()).isEmpty(); } + + // --- getPersonsByGeneration --- + + @Test + void getPersonsByGeneration_delegates_to_repository() { + Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build(); + when(personRepository.findByGeneration(2)).thenReturn(List.of(p)); + + List result = personService.getPersonsByGeneration(2); + + assertThat(result).containsExactly(p); + } + + @Test + void getPersonsByGeneration_returns_emptyList_when_no_match() { + when(personRepository.findByGeneration(99)).thenReturn(List.of()); + + List result = personService.getPersonsByGeneration(99); + + assertThat(result).isEmpty(); + } } -- 2.49.1 From f08b09faeb7d1b3539968401876b4a8c4693769f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:20:54 +0200 Subject: [PATCH 3/9] feat(timeline): add TimelineService assembly + 24-test Mockito suite 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 --- .../familienarchiv/timeline/TimelineDTO.java | 15 + .../timeline/TimelineFilter.java | 16 + .../timeline/TimelineService.java | 260 ++++++++++ .../timeline/TimelineYearDTO.java | 12 + .../shared/ArchitectureTest.java | 7 + .../timeline/TimelineServiceTest.java | 452 ++++++++++++++++++ 6 files changed, 762 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java new file mode 100644 index 00000000..bffe72fa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Assembled timeline response. Year bands are sorted ascending (oldest first). + * Undated entries have no usable date or {@code UNKNOWN} precision. + */ +public record TimelineDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List years, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List undated +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java new file mode 100644 index 00000000..0be25434 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineFilter.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.timeline; + +import java.util.UUID; + +/** + * Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}. + * All fields are nullable — null means "no constraint on this dimension". + */ +public record TimelineFilter( + UUID personId, + Integer generation, + EventType type, + Integer fromYear, + Integer toYear +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java new file mode 100644 index 00000000..50b008bd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -0,0 +1,260 @@ +package org.raddatz.familienarchiv.timeline; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; + +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Assembles the family timeline from three sources — curated {@link TimelineEvent} rows, + * derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}. + * + *

Cross-domain data is reached exclusively through domain services (PersonService, + * DocumentService). The only repository injected directly is {@link TimelineEventRepository} + * (same domain — constitution §1.3). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TimelineService { + + /** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */ + static final Comparator WITHIN_BAND_ORDER = + Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed() + .thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX) + .thenComparing(e -> e.title() != null ? e.title() : "") + .thenComparing(e -> { + if (e.eventId() != null) return e.eventId().toString(); + if (e.documentId() != null) return e.documentId().toString(); + return ""; + }); + + private final TimelineEventRepository eventRepository; + private final TimelineEventService timelineEventService; + private final DocumentService documentService; + private final PersonService personService; + + /** + * Assembles the timeline for the given filter. All filters are ANDed. + * Throws {@link DomainException} (bad request) when fromYear > toYear. + * Throws {@link DomainException} (not found) when personId refers to an unknown person. + */ + public TimelineDTO assemble(TimelineFilter filter) { + if (filter.fromYear() != null && filter.toYear() != null + && filter.fromYear() > filter.toYear()) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "toYear must not be before fromYear"); + } + + // Resolve generation person IDs once — used across all three layers + Set genPersonIds = resolveGenerationPersonIds(filter.generation()); + + // ── curated events ─────────────────────────────────────────────────── + List entries = new ArrayList<>(); + for (TimelineEvent ev : eventRepository.findAll()) { + if (!passesTypeFilter(ev.getType(), filter.type())) continue; + if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; + if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; + if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue; + entries.add(mapEvent(ev)); + } + + // ── derived events ─────────────────────────────────────────────────── + for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) { + if (!passesTypeFilter(derived.type(), filter.type())) continue; + if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue; + if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue; + if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue; + entries.add(derived); + } + + // ── letters ───────────────────────────────────────────────────────── + List docs = fetchDocuments(filter.personId()); + for (Document doc : docs) { + if (!passesLetterGenerationFilter(doc, genPersonIds)) continue; + if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue; + entries.add(mapDocument(doc)); + } + + return bucket(entries); + } + + // ─── Bucketing ─────────────────────────────────────────────────────────── + + Map> bucketByYear(List entries) { + Map> map = new TreeMap<>(); + for (TimelineEntryDTO e : entries) { + if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue; + map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e); + } + return map; + } + + private TimelineDTO bucket(List entries) { + List undated = entries.stream() + .filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) + .sorted(WITHIN_BAND_ORDER) + .toList(); + + Map> byYear = bucketByYear(entries); + List years = byYear.entrySet().stream() + .map(e -> new TimelineYearDTO(e.getKey(), + e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList())) + .toList(); + + return new TimelineDTO(years, undated); + } + + // ─── Document fetch (global vs personId path) ──────────────────────────── + + private List fetchDocuments(UUID personId) { + if (personId == null) { + return documentService.getAllForTimeline(); + } + // personId path: validate existence, then union sender+receiver (dedup by id) + personService.getById(personId); + Map seen = new LinkedHashMap<>(); + for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d); + for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d); + return new ArrayList<>(seen.values()); + } + + // ─── Filter predicates ─────────────────────────────────────────────────── + + private boolean passesTypeFilter(EventType entryType, EventType filterType) { + return filterType == null || filterType == entryType; + } + + private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) { + if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes + int year = date.getYear(); + if (filter.fromYear() != null && year < filter.fromYear()) return false; + if (filter.toYear() != null && year > filter.toYear()) return false; + return true; + } + + private boolean passesPersonFilter(Set persons, UUID personId) { + if (personId == null) return true; + return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId())); + } + + private boolean passesDerivedPersonFilter(List linkedIds, UUID personId) { + if (personId == null) return true; + return linkedIds != null && linkedIds.contains(personId); + } + + private Set resolveGenerationPersonIds(Integer generation) { + if (generation == null) return null; + return personService.getPersonsByGeneration(generation).stream() + .map(Person::getId) + .collect(Collectors.toSet()); + } + + private boolean passesGenerationFilter(Set persons, Set genPersonIds) { + if (genPersonIds == null) return true; + if (persons == null || persons.isEmpty()) return false; + return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId())); + } + + private boolean passesDerivedGenerationFilter(List linkedIds, Set genPersonIds) { + if (genPersonIds == null) return true; + if (linkedIds == null || linkedIds.isEmpty()) return false; + return linkedIds.stream().anyMatch(genPersonIds::contains); + } + + private boolean passesLetterGenerationFilter(Document doc, Set genPersonIds) { + if (genPersonIds == null) return true; + Person sender = doc.getSender(); + if (sender != null && genPersonIds.contains(sender.getId())) return true; + Set receivers = doc.getReceivers(); + if (receivers != null) { + return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId())); + } + return false; + } + + // ─── Mapping ───────────────────────────────────────────────────────────── + + private TimelineEntryDTO mapEvent(TimelineEvent ev) { + List personIds = ev.getPersons() == null ? List.of() + : ev.getPersons().stream().map(Person::getId).toList(); + return new TimelineEntryDTO( + Kind.EVENT, + ev.getPrecision(), + false, + "", + "", + ev.getEventDate(), + ev.getEventDateEnd(), + ev.getTitle(), + ev.getType(), + ev.getId(), + null, + personIds, + null + ); + } + + private TimelineEntryDTO mapDocument(Document doc) { + return new TimelineEntryDTO( + Kind.LETTER, + doc.getMetaDatePrecision(), + false, + resolveSenderName(doc), + resolveReceiverName(doc), + doc.getDocumentDate(), + null, + doc.getTitle(), + null, + null, + doc.getId(), + List.of(), + null + ); + } + + private String resolveSenderName(Document doc) { + if (doc.getSender() != null) return doc.getSender().getDisplayName(); + String text = doc.getSenderText(); + return (text != null && !text.isBlank()) ? text : ""; + } + + private String resolveReceiverName(Document doc) { + Set receivers = doc.getReceivers(); + if (receivers != null && !receivers.isEmpty()) { + return receivers.stream().findFirst().map(Person::getDisplayName).orElse(""); + } + String text = doc.getReceiverText(); + return (text != null && !text.isBlank()) ? text : ""; + } + + private static int precisionRank(DatePrecision precision) { + if (precision == null) return 0; + return switch (precision) { + case DAY -> 5; + case MONTH -> 4; + case SEASON -> 3; + case YEAR -> 2; + case APPROX -> 1; + default -> 0; + }; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java new file mode 100644 index 00000000..bc9c9772 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineYearDTO.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */ +public record TimelineYearDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List entries +) { +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java index 0d34b257..0adffde0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java @@ -100,6 +100,13 @@ class ArchitectureTest { .and().resideInAPackage("..audit..") .should().dependOnClassesThat(foreignJpaRepositoryFor("audit")); + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_timeline = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..timeline..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("timeline")); + // Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages. // Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages // where it can be audited and reasoned about independently. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java new file mode 100644 index 00000000..549c4f10 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java @@ -0,0 +1,452 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TimelineServiceTest { + + @Mock TimelineEventRepository eventRepository; + @Mock TimelineEventService timelineEventService; + @Mock DocumentService documentService; + @Mock PersonService personService; + + @InjectMocks TimelineService timelineService; + + // ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ──────────────────────── + + @Test + void within_band_order_day_precision_sorts_before_year() { + var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief"); + var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief"); + + var sorted = List.of(yearEntry, dayEntry).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted).containsExactly(dayEntry, yearEntry); + } + + @Test + void within_band_order_same_precision_and_date_sorts_alphabetically() { + var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer"); + var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler"); + + var sorted = List.of(entryZ, entryA).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted).containsExactly(entryA, entryZ); + } + + @Test + void within_band_order_same_title_uses_document_id_as_tiebreak() { + UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); + var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", + LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null); + var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", + LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null); + + var sorted = List.of(e2, e1).stream() + .sorted(TimelineService.WITHIN_BAND_ORDER) + .toList(); + + assertThat(sorted.get(0).documentId()).isEqualTo(id1); + } + + // ─── Assembly tests (issue-spec order) ────────────────────────────────── + + @Test + void test1_empty_archive_returns_empty_dto() { + // REQ-013, REQ-007 + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test2_one_year_letter_returns_one_year_band() { + // REQ-007 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test3a_null_date_letter_goes_to_undated() { + // REQ-003 + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).hasSize(1); + } + + @Test + void test3b_unknown_precision_letter_goes_to_undated() { + // REQ-003 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).hasSize(1); + } + + @Test + void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() { + // REQ-005 + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR) + .documentDate(LocalDate.of(1914, 1, 1)) + .build(); // no sender, no senderText, no receivers, no receiverText + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entry = result.years().get(0).entries().get(0); + assertThat(entry.senderName()).isEqualTo(""); + assertThat(entry.receiverName()).isEqualTo(""); + } + + @Test + void test5_day_precision_sorts_before_year_in_same_year_band() { + // REQ-002 + var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief"); + var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entries = result.years().get(0).entries(); + assertThat(entries).hasSize(2); + assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY); + assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void test6_same_precision_same_date_sorted_alphabetically_by_title() { + // REQ-002 + var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer"); + var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA)); + + TimelineDTO result = timelineService.assemble(noFilters()); + + var entries = result.years().get(0).entries(); + assertThat(entries).hasSize(2); + assertThat(entries.get(0).title()).isEqualTo("Adler"); + assertThat(entries.get(1).title()).isEqualTo("Zimmer"); + } + + @Test + void test7a_range_event_placed_only_in_start_year_band() { + // REQ-004 + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue(); + } + + @Test + void test7b_range_event_with_null_eventDateEnd_does_not_crash() { + // REQ-004 + var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL, + LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException(); + } + + @Test + void test8_range_event_excluded_when_start_year_before_fromYear() { + // REQ-004 + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + // fromYear=1915 → start year 1914 is outside → excluded + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null)); + + assertThat(result.years()).isEmpty(); + } + + @Test + void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() { + // REQ-009 + var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief"); + var historicalEvent = event("Sarajevo", EventType.HISTORICAL, + LocalDate.of(1914, 6, 28), DatePrecision.DAY, null); + var personalEvent = event("Geburt", EventType.PERSONAL, + LocalDate.of(1914, 8, 1), DatePrecision.DAY, null); + when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + + // filter: only HISTORICAL events + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null)); + + long letters = result.years().stream().flatMap(y -> y.entries().stream()) + .filter(e -> e.kind() == Kind.LETTER).count(); + long personalEvents = result.years().stream().flatMap(y -> y.entries().stream()) + .filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count(); + assertThat(letters).isEqualTo(1); + assertThat(personalEvents).isEqualTo(0); + } + + @Test + void test9b_generation_filter_includes_letter_when_sender_matches_generation() { + // REQ-010 + var sender = Person.builder().id(UUID.randomUUID()) + .lastName("Mustermann").firstName("Max").generation(2).build(); + var included = Document.builder().id(UUID.randomUUID()).title("Treffer") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(sender).build(); + var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded)); + when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer"); + } + + @Test + void test9c_fromYear_toYear_inclusive_single_year_window() { + // REQ-011 + var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher"); + var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr"); + var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr"); + } + + @Test + void test10_adversarial_and_logic_neither_event_passes_both_filters() { + // REQ-012 — type AND year must both pass + var wrongType = event("Personal", EventType.PERSONAL, + LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null); + var wrongYear = event("Historical outside", EventType.HISTORICAL, + LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null); + when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() { + // REQ-008 + UUID personId = UUID.randomUUID(); + var person = Person.builder().id(personId).lastName("Mustermann").build(); + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(person) + .receivers(Set.of(person)) + .build(); + when(personService.getById(personId)).thenReturn(person); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc)); + when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null)); + + long total = result.years().stream().mapToLong(y -> y.entries().size()).sum() + + result.undated().size(); + assertThat(total).isEqualTo(1); + } + + @Test + void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() { + // REQ-012 + UUID personId = UUID.randomUUID(); + var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build(); + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(person).build(); + var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build(); + when(personService.getById(personId)).thenReturn(person); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc)); + when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of()); + when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2 + + TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test13_null_generation_sender_not_returned_by_generation_filter() { + // REQ-020 — both sender and receiver have null generation → excluded + var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null + var doc = Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1)) + .sender(nullGenSender).build(); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(doc)); + when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1 + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null)); + + assertThat(result.years()).isEmpty(); + assertThat(result.undated()).isEmpty(); + } + + @Test + void test14_year_band_contains_only_event_when_no_letters_in_that_year() { + var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null); + when(eventRepository.findAll()).thenReturn(List.of(ev)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(noFilters()); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).entries()).hasSize(1); + assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT); + } + + @Test + void test15_range_event_start_year_equal_to_fromYear_is_included() { + // REQ-004 — inclusive lower bound + var rangeEvent = event("WW1", EventType.HISTORICAL, + LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11)); + when(eventRepository.findAll()).thenReturn(List.of(rangeEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of()); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null)); + + assertThat(result.years()).hasSize(1); + assertThat(result.years().get(0).year()).isEqualTo(1914); + } + + @Test + void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() { + // REQ-011 + var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt"); + var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst"); + var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer"); + when(eventRepository.findAll()).thenReturn(List.of()); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer)); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null)); + + assertThat(result.years()).hasSize(2); + assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue(); + } + + @Test + void fromYear_greater_than_toYear_throws_bad_request() { + // REQ-016 (service-layer guard) + assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914))) + .isInstanceOf(DomainException.class); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static TimelineFilter noFilters() { + return new TimelineFilter(null, null, null, null, null); + } + + private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) { + return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "", + date, null, title, null, null, UUID.randomUUID(), List.of(), null); + } + + private static Document docWithDate(LocalDate date, DatePrecision precision) { + return Document.builder().id(UUID.randomUUID()).title("Brief") + .metaDatePrecision(precision).documentDate(date).build(); + } + + private static Document docWithDate(LocalDate date, DatePrecision precision, String title) { + return Document.builder().id(UUID.randomUUID()).title(title) + .metaDatePrecision(precision).documentDate(date).build(); + } + + private static TimelineEvent event(String title, EventType type, LocalDate date, + DatePrecision precision, LocalDate endDate) { + return TimelineEvent.builder().id(UUID.randomUUID()) + .title(title).type(type) + .eventDate(date).precision(precision).eventDateEnd(endDate) + .build(); + } +} -- 2.49.1 From afd1f0b86b956b31ebbcd4894ece8cdcd40d1a86 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:22:44 +0200 Subject: [PATCH 4/9] feat(timeline): add GET /api/timeline endpoint + 8-test controller suite 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 --- .../timeline/TimelineController.java | 33 +++++ .../timeline/TimelineControllerTest.java | 139 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java new file mode 100644 index 00000000..31992a8e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineController.java @@ -0,0 +1,33 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/timeline") +@Validated +@RequiredArgsConstructor +public class TimelineController { + + private final TimelineService timelineService; + + @GetMapping + @RequirePermission(Permission.READ_ALL) + public TimelineDTO getTimeline( + @RequestParam(required = false) UUID personId, + @RequestParam(required = false) @Min(0) Integer generation, + @RequestParam(required = false) EventType type, + @RequestParam(required = false) Integer fromYear, + @RequestParam(required = false) Integer toYear) { + return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java new file mode 100644 index 00000000..61c81496 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineControllerTest.java @@ -0,0 +1,139 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.CustomUserDetailsService; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TimelineController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TimelineControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TimelineService timelineService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of()); + + @BeforeEach + void resolveDefaultPrincipal() { + when(userService.findByEmail("user")) + .thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build()); + } + + // ─── Security ───────────────────────────────────────────────────────────── + + @Test + void returns_401_when_unauthenticated() throws Exception { + // REQ-014 + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isUnauthorized()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL") + void returns_403_when_authenticated_without_read_all() throws Exception { + // REQ-015 + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isForbidden()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_200_with_read_all_permission() throws Exception { + // REQ-001 + when(timelineService.assemble(any())).thenReturn(EMPTY); + + mockMvc.perform(get("/api/timeline")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.years").isArray()) + .andExpect(jsonPath("$.undated").isArray()); + } + + // ─── Parameter binding ──────────────────────────────────────────────────── + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void valid_params_are_forwarded_to_service() throws Exception { + UUID personId = UUID.randomUUID(); + when(timelineService.assemble(any())).thenReturn(EMPTY); + + mockMvc.perform(get("/api/timeline") + .param("personId", personId.toString()) + .param("generation", "2") + .param("type", "HISTORICAL") + .param("fromYear", "1914") + .param("toYear", "1918")) + .andExpect(status().isOk()); + + verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918)); + } + + // ─── Validation errors ──────────────────────────────────────────────────── + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_on_bad_type_value() throws Exception { + // REQ-018 — Spring enum binding rejects unknown value + mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_when_fromYear_greater_than_toYear() throws Exception { + // REQ-016 — service throws bad request, controller propagates it + when(timelineService.assemble(any())) + .thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "toYear must not be before fromYear")); + + mockMvc.perform(get("/api/timeline") + .param("fromYear", "1920") + .param("toYear", "1914")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_400_when_generation_is_negative() throws Exception { + // REQ-017 — @Min(0) on generation parameter + mockMvc.perform(get("/api/timeline").param("generation", "-1")) + .andExpect(status().isBadRequest()); + } + + @Test + @org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL") + void returns_404_when_person_not_found() throws Exception { + // REQ-019 + when(timelineService.assemble(any())) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND"))); + } +} -- 2.49.1 From 3a174dd91b2ec515d5e67851b4589d64ec3edabe Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:24:34 +0200 Subject: [PATCH 5/9] test(timeline): add integration tests for TimelineService + findByGeneration 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 --- .../TimelineServiceIntegrationTest.java | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceIntegrationTest.java new file mode 100644 index 00000000..60274f8d --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceIntegrationTest.java @@ -0,0 +1,105 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration} + * against real Postgres. Verifies that assembled output reflects persisted curated events and + * that the generation query handles null-generation rows correctly. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class TimelineServiceIntegrationTest { + + @MockitoBean S3Client s3Client; + + @Autowired TimelineService timelineService; + @Autowired TimelineEventRepository timelineEventRepository; + @Autowired PersonRepository personRepository; + + @PersistenceContext EntityManager em; + + // ─── PersonRepository.findByGeneration ──────────────────────────────────── + + @Test + void findByGeneration_returns_matching_persons() { + personRepository.save(Person.builder().lastName("Gen2A").generation(2).build()); + personRepository.save(Person.builder().lastName("Gen2B").generation(2).build()); + personRepository.save(Person.builder().lastName("Gen3").generation(3).build()); + em.flush(); + + List result = personRepository.findByGeneration(2); + + assertThat(result).extracting(Person::getLastName) + .containsExactlyInAnyOrder("Gen2A", "Gen2B"); + } + + @Test + void findByGeneration_returns_empty_list_not_npe_when_no_match() { + personRepository.save(Person.builder().lastName("Gen1").generation(1).build()); + em.flush(); + + List result = personRepository.findByGeneration(99); + + assertThat(result).isNotNull().isEmpty(); + } + + @Test + void findByGeneration_does_not_return_null_generation_persons() { + personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null + em.flush(); + + List result = personRepository.findByGeneration(1); + + assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen"); + } + + // ─── TimelineService.assemble end-to-end ───────────────────────────────── + + @Test + void assemble_includes_persisted_curated_event_in_correct_year_band() { + UUID actorId = UUID.randomUUID(); + TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder() + .title("Sarajevo") + .type(EventType.HISTORICAL) + .eventDate(LocalDate.of(1914, 6, 28)) + .precision(DatePrecision.DAY) + .createdBy(actorId) + .updatedBy(actorId) + .build()); + em.flush(); + em.clear(); + + TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null)); + + assertThat(result.years()).anySatisfy(y -> { + assertThat(y.year()).isEqualTo(1914); + assertThat(y.entries()).anySatisfy(e -> { + assertThat(e.title()).isEqualTo("Sarajevo"); + assertThat(e.kind()).isEqualTo(Kind.EVENT); + assertThat(e.eventId()).isEqualTo(event.getId()); + }); + }); + } +} -- 2.49.1 From 5017d17b1130fe174bd211d76a59ae4993f21818 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:43:24 +0200 Subject: [PATCH 6/9] chore(api): regenerate TypeScript types for GET /api/timeline 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 --- frontend/src/lib/generated/api.ts | 78 ++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f275c9f2..f2fc43f5 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1032,6 +1032,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/timeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getTimeline"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tags": { parameters: { query?: never; @@ -2413,6 +2429,38 @@ export interface components { contributors: components["schemas"]["ActivityActorDTO"][]; hasMoreContributors: boolean; }; + TimelineDTO: { + years: components["schemas"]["TimelineYearDTO"][]; + undated: components["schemas"]["TimelineEntryDTO"][]; + }; + TimelineEntryDTO: { + /** @enum {string} */ + kind: "EVENT" | "LETTER"; + /** @enum {string} */ + precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + derived: boolean; + senderName: string; + receiverName: string; + /** Format: date */ + eventDate?: string; + /** Format: date */ + eventDateEnd?: string; + title?: string; + /** @enum {string} */ + type?: "PERSONAL" | "HISTORICAL"; + /** Format: uuid */ + eventId?: string; + /** Format: uuid */ + documentId?: string; + linkedPersonIds?: string[]; + /** @enum {string} */ + derivedType?: "BIRTH" | "DEATH" | "MARRIAGE"; + }; + TimelineYearDTO: { + /** Format: int32 */ + year: number; + entries: components["schemas"]["TimelineEntryDTO"][]; + }; TagTreeNodeDTO: { /** Format: uuid */ id: string; @@ -2468,10 +2516,10 @@ export interface components { birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; /** Format: date */ deathDate?: string; - /** @enum {string} */ - deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; personType?: string; familyMember?: boolean; + /** @enum {string} */ + deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; provisional?: boolean; /** Format: int32 */ birthYear?: number; @@ -4993,6 +5041,32 @@ export interface operations { }; }; }; + getTimeline: { + parameters: { + query?: { + personId?: string; + generation?: number; + type?: "PERSONAL" | "HISTORICAL"; + fromYear?: number; + toYear?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TimelineDTO"]; + }; + }; + }; + }; searchTags: { parameters: { query?: { -- 2.49.1 From 1de314f49b88b6acf37a106509f9cd2bb75e71ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 16:46:24 +0200 Subject: [PATCH 7/9] docs(timeline): RTM, CLAUDE.md, and C4 updates for #777 assembly endpoint - 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 --- .specify/rtm.md | 20 +++++++++++++++++++ CLAUDE.md | 2 +- docs/architecture/c4/l3-backend-timeline.puml | 14 ++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index 8eca9b38..a8a79d6c 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -59,3 +59,23 @@ | REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done | | REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done | | REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done | +| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done | +| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done | +| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done | +| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done | +| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done | +| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done | +| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done | +| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done | +| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done | +| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done | +| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done | +| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done | +| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done | +| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done | +| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done | +| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done | +| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done | +| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done | +| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done | +| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done | diff --git a/CLAUDE.md b/CLAUDE.md index b218b4b7..eb03fb4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/ │ └── relationship/ PersonRelationship sub-domain ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── tag/ Tag domain -├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data +├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline └── user/ User domain — AppUser, UserGroup, UserService ``` diff --git a/docs/architecture/c4/l3-backend-timeline.puml b/docs/architecture/c4/l3-backend-timeline.puml index 89669943..67715c46 100644 --- a/docs/architecture/c4/l3-backend-timeline.puml +++ b/docs/architecture/c4/l3-backend-timeline.puml @@ -9,11 +9,14 @@ System_Boundary(backend, "API Backend (Spring Boot)") { Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).") Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).") - Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).") + Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).") + + Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.") + Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.") } -System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters") -System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and curated-event links") +System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch") +System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering") Rel(timelineRepo, db, "SQL queries", "JDBC") Rel(timelineSvc, timelineRepo, "Reads / writes events") @@ -21,5 +24,10 @@ Rel(timelineCtrl, timelineSvc, "Delegates to") Rel(timelineRepo, personDomain, "References persons via join table") Rel(timelineRepo, documentDomain, "References documents via join table") Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly") +Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to") +Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events") +Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events") +Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters") +Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer") @enduml -- 2.49.1 From 590b00d2d77ae0257379ee22300b33dad097dc8f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 17:23:10 +0200 Subject: [PATCH 8/9] fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../timeline/TimelineService.java | 8 +++ .../timeline/TimelineServiceLazyLoadTest.java | 72 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java index 50b008bd..f55ccd91 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Comparator; @@ -56,7 +57,14 @@ public class TimelineService { * Assembles the timeline for the given filter. All filters are ANDed. * Throws {@link DomainException} (bad request) when fromYear > toYear. * Throws {@link DomainException} (not found) when personId refers to an unknown person. + * + *

{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads, + * this method accesses lazy collections ({@link TimelineEvent#getPersons()}, + * {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the + * repository sub-transaction closes. Without this annotation those accesses throw + * {@link org.hibernate.LazyInitializationException} in production (constitution §1.6). */ + @Transactional(readOnly = true) public TimelineDTO assemble(TimelineFilter filter) { if (filter.fromYear() != null && filter.toYear() != null && filter.fromYear() > filter.toYear()) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java new file mode 100644 index 00000000..39c25fd3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java @@ -0,0 +1,72 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Verifies that {@link TimelineService#assemble} does not throw + * {@link org.hibernate.LazyInitializationException} when events have linked persons. + * + *

No class-level {@code @Transactional} — each test method runs without an outer + * transaction, matching production behaviour (controller has no {@code @Transactional}). + * If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing + * {@code ev.getPersons()} on detached entities throws LazyInitializationException. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class TimelineServiceLazyLoadTest { + + @MockitoBean + S3Client s3Client; + + @Autowired + TransactionTemplate transactionTemplate; + + @Autowired + TimelineService timelineService; + + @Autowired + TimelineEventRepository timelineEventRepository; + + @Autowired + PersonRepository personRepository; + + @Test + void assemble_does_not_throw_when_event_has_linked_persons() { + UUID actorId = UUID.randomUUID(); + // Commit outside any test-managed transaction so entities are detached on return + transactionTemplate.execute(status -> { + Person person = personRepository.save(Person.builder().lastName("Müller").build()); + timelineEventRepository.save(TimelineEvent.builder() + .title("Linked event") + .type(EventType.HISTORICAL) + .eventDate(LocalDate.of(1914, 7, 28)) + .precision(DatePrecision.DAY) + .createdBy(actorId) + .updatedBy(actorId) + .persons(new HashSet<>(Set.of(person))) + .build()); + return null; + }); + + assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null))); + } +} -- 2.49.1 From 1348255ae3ec5ea07fbf035e03c20582af7cb079 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 17:24:01 +0200 Subject: [PATCH 9/9] docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md 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 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb03fb4c..8fbfe119 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ backend/src/main/java/org/raddatz/familienarchiv/ | `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | | `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` | | `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail | -| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for curated + derived timeline events (`derived=false/true`); `id: String` (UUID for curated, prefixed synthetic for derived: `birth:`, `death:`, `marriage:`); `DerivedEventType` (`BIRTH`/`DEATH`/`MARRIAGE`) discriminator; `primaryPersonName` + `relatedPersonName` for localized label composition in #6/#7 | +| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` -- 2.49.1