From 663ffad49bc5d48c36d4dfc06aa2307674e2ae70 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:28:48 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docs(adr):=20add=20ADR-043=20=E2=80=94=20de?= =?UTF-8?q?rived=20person=20life-events=20on-read=20strategy=20(Proposed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers three pre-implementation decisions for issue #776: 1. On-read assembly, never persisted (no migration) 2. Synthetic prefixed String ids (birth:/death:/marriage:) 3. assembleDerivedEvents() as the public cross-issue contract on TimelineService Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- docs/adr/043-derived-person-events.md | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/adr/043-derived-person-events.md diff --git a/docs/adr/043-derived-person-events.md b/docs/adr/043-derived-person-events.md new file mode 100644 index 00000000..9b50de01 --- /dev/null +++ b/docs/adr/043-derived-person-events.md @@ -0,0 +1,110 @@ +# ADR-043 — Derived person life-events: on-read assembly strategy + +**Status:** Proposed +**Date:** 2026-06-13 +**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat) + +--- + +## Context + +The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside +manually curated `TimelineEvent` rows. This data already exists in the `Person` entity +(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in +`PersonRelationship` rows with `relationType = SPOUSE_OF`. + +Three architectural decisions needed before implementation could start: + +1. **Computation strategy:** should derived events be materialised to the `timeline_events` + table, or assembled on every read from the source tables? +2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide + with real `TimelineEvent` UUIDs and signal read-only semantics to consumers? +3. **Service contract:** where does the assembly method live, and what is its public API? + +--- + +## Decision 1 — On-read assembly, never persisted + +Derived events are computed on every call to `assembleDerivedEvents()` and are never written +to any table. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. | +| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. | +| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. | + +**Consequences:** +- No schema changes. No Flyway migration. +- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open + across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH. +- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member + persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale. + +--- + +## Decision 2 — Synthetic prefixed String ids + +Derived events receive ids of the form `birth:{personId}`, `death:{personId}`, +`marriage:{relationshipId}`, where the suffix is the UUID of the source entity. + +**Format rules:** +- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`. +- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is + structurally non-UUID by construction. +- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages; + the in-memory `Set` used during assembly is a defensive assertion, not primary + enforcement. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. | +| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. | +| Numeric sequence | No natural source sequence; would require a counter, adding state. | + +**Consequences:** +- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and + serves a different purpose (CRUD admin view); it is not changed. +- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that + do not parse as `UUID` — enforced and tested in issue #5, not here. +- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side + caching and deduplication. + +--- + +## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract + +The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the +`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean. + +**Domain boundary rules enforced by this decision:** +- `TimelineService` reaches `Person` and `PersonRelationship` data **only through + `PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**. + It never injects `PersonRepository` or `PersonRelationshipRepository`. +- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`, + `buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public. +- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling + endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller + outside #5 must do the same — this obligation is documented in the Javadoc of the method. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. | +| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. | + +--- + +## Related decisions + +- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this + issue reads) +- ADR-040 — Timeline domain data model (establishes the `timeline/` package and + `TimelineEvent` entity this issue extends) +- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns + `List`, not raw `Person` or `PersonRelationship` entities) -- 2.49.1 From 4245b821b9a2b302e70140c859ad81c14422058a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:29:36 +0200 Subject: [PATCH 2/6] feat(timeline): add DerivedEventType enum and TimelineEntryDTO record DerivedEventType: BIRTH / DEATH / MARRIAGE discriminator for derived events. TimelineEntryDTO: unified String-id DTO for both curated and derived events; id is String (not UUID) to accommodate synthetic prefixed ids (birth:/death:/marriage:). Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/DerivedEventType.java | 8 +++++ .../timeline/TimelineEntryDTO.java | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/DerivedEventType.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/DerivedEventType.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/DerivedEventType.java new file mode 100644 index 00000000..3616c678 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/DerivedEventType.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.timeline; + +/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */ +public enum DerivedEventType { + BIRTH, + DEATH, + MARRIAGE +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java new file mode 100644 index 00000000..9f729be9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java @@ -0,0 +1,31 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; + +/** + * 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}). + * + *

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. + * + *

Callers of {@code TimelineService.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) DatePrecision precision, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived, + DerivedEventType derivedType, + String primaryPersonName, + String relatedPersonName +) { +} -- 2.49.1 From 7810ca7dd7b9fe1b2656d12cc24fbac7c047b53d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:30:30 +0200 Subject: [PATCH 3/6] feat(relationship): add findAllSpouseEdges() for timeline assembly Returns all SPOUSE_OF edges with JOIN FETCH on both person sides, preventing N+1 in TimelineService.assembleDerivedEvents() (REQ-011). Reuses existing findAllByRelationTypeIn query which already JOIN FETCHes. Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- .../person/relationship/RelationshipService.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index 51b312c6..ee237617 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java @@ -86,6 +86,15 @@ public class RelationshipService { return new NetworkDTO(nodes, edges); } + /** + * Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed. + * Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events + * without per-edge N+1 queries. + */ + public List findAllSpouseEdges() { + return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF)); + } + @Transactional public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { if (personId.equals(dto.relatedPersonId())) { -- 2.49.1 From c66d83cfc6c4d50184add626b97bfc4c5e0f1ec2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:35:50 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(timeline):=20implement=20assembleDeriv?= =?UTF-8?q?edEvents()=20with=20TDD=20(REQ-001=E2=80=93REQ-016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RelationshipService dependency to TimelineEventService and implements: - assembleDerivedEvents() — public @Transactional(readOnly=true) orchestrator - buildBirthEvents() — Person.birthDate → BIRTH events with precision pass-through - buildDeathEvents() — Person.deathDate → DEATH events with precision pass-through - buildMarriageEvents() — SPOUSE_OF edges → MARRIAGE events, dedup on row id Synthetic prefixed ids (birth:/death:/marriage:) are structurally non-UUID. Null fromYear marriages are emitted with eventDate=null + precision=UNKNOWN (REQ-006). Non-family-member persons excluded from birth/death; SPOUSE_OF edges always emit (REQ-013). All 16 tests in DerivedEventsAssemblyTest pass. Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/TimelineEventService.java | 88 ++++ .../timeline/DerivedEventsAssemblyTest.java | 378 ++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java 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(); + } +} -- 2.49.1 From 033001559d3b2ca97d6f1d8d22fb6892270dd9fe Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:37:19 +0200 Subject: [PATCH 5/6] docs(timeline): update RTM and CLAUDE.md for issue #776 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTM: add REQ-001–REQ-016 rows with Done status, implementation files, and test IDs. CLAUDE.md: expand timeline package entry with TimelineEntryDTO, DerivedEventType, and assembleDerivedEvents(); add TimelineEntryDTO to domain model table. Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- .specify/rtm.md | 16 ++++++++++++++++ CLAUDE.md | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index 5547778d..8eca9b38 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -43,3 +43,19 @@ | REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done | +| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done | +| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done | +| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done | +| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done | +| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done | +| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done | +| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done | +| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done | +| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done | +| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done | +| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done | +| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done | +| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done | +| 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 | diff --git a/CLAUDE.md b/CLAUDE.md index e8932627..b218b4b7 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, EventType, TimelineEventRepository +├── 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 └── user/ User domain — AppUser, UserGroup, UserService ``` @@ -121,6 +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 | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` -- 2.49.1 From 6b593a7bc61e4fd9d4fd79593eef9982750d981c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:53:50 +0200 Subject: [PATCH 6/6] docs(timeline): add derived-event glossary entries and update C4 diagram Add GLOSSARY.md entries for derived event, DerivedEventType, derivedType, and assembleDerivedEvents() to cover the vocabulary introduced by #776. Update l3-backend-timeline.puml: remove stale "planned, #775" labels, add Rel from TimelineEventService to personDomain for assembleDerivedEvents batch-fetch calls, document the on-read strategy in the component notes. Refs #776 Co-Authored-By: claude-sonnet-4-6 --- docs/GLOSSARY.md | 11 ++++++++++- docs/architecture/c4/l3-backend-timeline.puml | 13 +++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index e6f50f52..29cc18e1 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -168,7 +168,16 @@ _Not to be confused with a document item's optional note_ — a document item's **EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040). -**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain. +**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain. + +**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043). +_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database. + +**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO. + +**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events. + +**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043. **Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table. diff --git a/docs/architecture/c4/l3-backend-timeline.puml b/docs/architecture/c4/l3-backend-timeline.puml index b0072429..89669943 100644 --- a/docs/architecture/c4/l3-backend-timeline.puml +++ b/docs/architecture/c4/l3-backend-timeline.puml @@ -6,19 +6,20 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl) ContainerDb(db, "PostgreSQL", "PostgreSQL 16") 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). Issue #774 ships the repository empty; the per-person filter query lands in #777.") + 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 (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.") - Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).") + 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).") } System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters") -System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves") +System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and curated-event links") Rel(timelineRepo, db, "SQL queries", "JDBC") -Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)") -Rel(timelineCtrl, timelineSvc, "Delegates to (planned)") +Rel(timelineSvc, timelineRepo, "Reads / writes events") +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") @enduml -- 2.49.1