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 e7c0d892..3a50d4bf 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
@@ -10,6 +10,8 @@ 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.raddatz.familienarchiv.person.relationship.PersonRelationship;
+import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
@@ -40,6 +42,7 @@ public class TimelineEventService {
private final TimelineEventRepository events;
private final PersonService personService;
private final DocumentService documentService;
+ private final RelationshipService relationshipService;
@Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
@@ -229,6 +232,91 @@ public class TimelineEventService {
return resolved;
}
+ // --- derived event assembly ---
+
+ /**
+ * Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
+ * PersonRelationship data. Computed on read, never persisted.
+ *
+ *
Derived events are computed, never persisted, and cannot be mutated via the events API
+ * (enforced in #5). Ids produced by this method are structurally non-UUID
+ * ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
+ * write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
+ * independently enforce {@code READ_ALL} authorization before invoking this method
+ * (see ADR-043).
+ */
+ @Transactional(readOnly = true)
+ public List assembleDerivedEvents() {
+ List persons = personService.findAllFamilyMembers();
+ List spouseEdges = relationshipService.findAllSpouseEdges();
+
+ List result = new ArrayList<>();
+ result.addAll(buildBirthEvents(persons));
+ result.addAll(buildDeathEvents(persons));
+ result.addAll(buildMarriageEvents(spouseEdges));
+
+ log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
+ return result;
+ }
+
+ private List buildBirthEvents(List persons) {
+ 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))
+ .toList();
+ }
+
+ private List buildDeathEvents(List persons) {
+ 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))
+ .toList();
+ }
+
+ private List buildMarriageEvents(List spouseEdges) {
+ // DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
+ // in-memory dedup on relationship row id is a defensive assertion.
+ Set seen = new HashSet<>();
+ List result = new ArrayList<>();
+ for (PersonRelationship r : spouseEdges) {
+ if (seen.add(r.getId())) {
+ // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
+ LocalDate eventDate = r.getFromYear() != null
+ ? LocalDate.of(r.getFromYear(), 1, 1)
+ : null;
+ DatePrecision precision = r.getFromYear() != null
+ ? DatePrecision.YEAR
+ : DatePrecision.UNKNOWN;
+ result.add(new TimelineEntryDTO(
+ "marriage:" + r.getId(),
+ EventType.PERSONAL,
+ eventDate,
+ precision,
+ true,
+ DerivedEventType.MARRIAGE,
+ r.getPerson().getDisplayName(),
+ r.getRelatedPerson().getDisplayName()));
+ }
+ }
+ return result;
+ }
+
// --- view assembly (explicit allow-list; never the raw entity) ---
private TimelineEventView toView(TimelineEvent event) {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java
new file mode 100644
index 00000000..9566b1fd
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java
@@ -0,0 +1,378 @@
+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.DocumentService;
+import org.raddatz.familienarchiv.person.Person;
+import org.raddatz.familienarchiv.person.PersonService;
+import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
+import org.raddatz.familienarchiv.person.relationship.RelationshipService;
+import org.raddatz.familienarchiv.person.relationship.RelationType;
+
+import java.time.LocalDate;
+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)
+class DerivedEventsAssemblyTest {
+
+ @Mock private TimelineEventRepository events;
+ @Mock private PersonService personService;
+ @Mock private DocumentService documentService;
+ @Mock private RelationshipService relationshipService;
+
+ @InjectMocks private TimelineEventService service;
+
+ // --- factory helpers ---
+
+ private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
+ return Person.builder()
+ .id(UUID.randomUUID())
+ .firstName("Anna")
+ .lastName("Müller")
+ .familyMember(true)
+ .birthDate(birthDate)
+ .birthDatePrecision(birthPrecision)
+ .build();
+ }
+
+ private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
+ return Person.builder()
+ .id(UUID.randomUUID())
+ .firstName("Hans")
+ .lastName("Raddatz")
+ .familyMember(true)
+ .deathDate(deathDate)
+ .deathDatePrecision(deathPrecision)
+ .build();
+ }
+
+ private Person makePersonWithBoth(
+ LocalDate birthDate, DatePrecision birthPrecision,
+ LocalDate deathDate, DatePrecision deathPrecision) {
+ return Person.builder()
+ .id(UUID.randomUUID())
+ .firstName("Anna")
+ .lastName("Müller")
+ .familyMember(true)
+ .birthDate(birthDate)
+ .birthDatePrecision(birthPrecision)
+ .deathDate(deathDate)
+ .deathDatePrecision(deathPrecision)
+ .build();
+ }
+
+ private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
+ return Person.builder()
+ .id(UUID.randomUUID())
+ .firstName("Anna")
+ .lastName("Müller")
+ .familyMember(false)
+ .birthDate(birthDate)
+ .birthDatePrecision(precision)
+ .build();
+ }
+
+ private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
+ return PersonRelationship.builder()
+ .id(UUID.randomUUID())
+ .person(a)
+ .relatedPerson(b)
+ .relationType(RelationType.SPOUSE_OF)
+ .fromYear(fromYear)
+ .build();
+ }
+
+ // --- REQ-001: birth events ---
+
+ @Test
+ void should_emit_one_geburt_for_person_with_birthdate() {
+ Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).hasSize(1);
+ TimelineEntryDTO event = result.get(0);
+ assertThat(event.derived()).isTrue();
+ assertThat(event.type()).isEqualTo(EventType.PERSONAL);
+ 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());
+ }
+
+ // --- REQ-003: null birthDate → no Geburt event ---
+
+ @Test
+ void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
+ Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ long todCount = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.DEATH)
+ .count();
+ assertThat(todCount).isZero();
+ }
+
+ // --- REQ-004: null deathDate → no Tod event ---
+
+ @Test
+ void should_emit_no_events_for_person_with_neither_date() {
+ Person nobody = Person.builder()
+ .id(UUID.randomUUID())
+ .firstName("Hans")
+ .lastName("Raddatz")
+ .familyMember(true)
+ .build();
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).isEmpty();
+ }
+
+ // --- REQ-002: death events ---
+
+ @Test
+ void should_emit_one_tod_for_person_with_deathdate() {
+ Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).hasSize(1);
+ TimelineEntryDTO event = result.get(0);
+ assertThat(event.derived()).isTrue();
+ assertThat(event.type()).isEqualTo(EventType.PERSONAL);
+ 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());
+ }
+
+ // --- REQ-002 + REQ-003 combined ---
+
+ @Test
+ void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
+ Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
+ }
+
+ // --- REQ-005: Heirat with fromYear ---
+
+ @Test
+ void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
+
+ List result = service.assembleDerivedEvents();
+
+ List heiraten = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .toList();
+ assertThat(heiraten).hasSize(1);
+ TimelineEntryDTO heirat = heiraten.get(0);
+ assertThat(heirat.derived()).isTrue();
+ assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
+ assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
+ assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
+ assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
+ }
+
+ // --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
+
+ @Test
+ void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge = makeSpouseEdge(anna, hans, null);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
+
+ List result = service.assembleDerivedEvents();
+
+ List heiraten = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .toList();
+ assertThat(heiraten).hasSize(1);
+ TimelineEntryDTO heirat = heiraten.get(0);
+ assertThat(heirat.eventDate()).isNull();
+ assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
+ }
+
+ // --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
+
+ @Test
+ void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person hans = makePerson(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
+
+ List result = service.assembleDerivedEvents();
+
+ long heiratCount = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .count();
+ assertThat(heiratCount).isEqualTo(1);
+ }
+
+ @Test
+ void should_emit_two_heirat_for_person_married_to_two_partners() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person hans = makePerson(null, DatePrecision.UNKNOWN);
+ Person karl = makePerson(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
+ PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
+
+ List result = service.assembleDerivedEvents();
+
+ long heiratCount = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .count();
+ assertThat(heiratCount).isEqualTo(2);
+ }
+
+ // --- REQ-001 precision pass-through ---
+
+ @Test
+ void should_pass_birth_precision_through_unchanged() {
+ Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
+ }
+
+ // --- REQ-008: synthetic prefixed ids, never UUID ---
+
+ @Test
+ void should_mint_prefixed_synthetic_ids_never_uuid() {
+ Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ 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);
+ }
+
+ // --- REQ-010: display names on Heirat ---
+
+ @Test
+ void should_emit_heirat_with_displayname_for_both_spouses() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
+
+ List heiraten = service.assembleDerivedEvents().stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .toList();
+
+ assertThat(heiraten).hasSize(1);
+ TimelineEntryDTO heirat = heiraten.get(0);
+ assertThat(heirat.primaryPersonName()).isNotNull().isNotBlank();
+ assertThat(heirat.relatedPersonName()).isNotNull().isNotBlank();
+ }
+
+ // --- REQ-007 note: assumption/documentation test ---
+
+ @Test
+ void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
+ // Assumption test — documents that the DB constraint prevents self-edges;
+ // the service does not guard this itself.
+ // The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
+ // This test verifies that if an edge were somehow inserted (impossible in prod),
+ // the service would still produce one event (not zero or an exception).
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
+
+ List result = service.assembleDerivedEvents();
+
+ long heiratCount = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .count();
+ assertThat(heiratCount).isEqualTo(1);
+ }
+
+ // --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
+
+ @Test
+ void should_exclude_non_family_member_persons_from_derived_events() {
+ Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of());
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).isEmpty();
+ }
+
+ // --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
+
+ @Test
+ void should_emit_heirat_when_one_spouse_is_not_family_member() {
+ Person anna = makePerson(null, DatePrecision.UNKNOWN);
+ Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
+ PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
+ when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
+
+ List result = service.assembleDerivedEvents();
+
+ long heiratCount = result.stream()
+ .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
+ .count();
+ assertThat(heiratCount).isEqualTo(1);
+ }
+
+ // --- REQ-014: empty family-member list → empty result, no error ---
+
+ @Test
+ void should_emit_zero_events_when_no_family_members() {
+ when(personService.findAllFamilyMembers()).thenReturn(List.of());
+ when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
+
+ List result = service.assembleDerivedEvents();
+
+ assertThat(result).isEmpty();
+ }
+}