feat(timeline): derive person life-events (Geburt/Tod/Heirat) — issue #776 #825
@@ -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 |
|
||||
|
||||
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
|
||||
| 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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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<PersonRelationship> findAllSpouseEdges() {
|
||||
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||
if (personId.equals(dto.relatedPersonId())) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}).
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
) {
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<TimelineEntryDTO> assembleDerivedEvents() {
|
||||
List<Person> persons = personService.findAllFamilyMembers();
|
||||
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
|
||||
|
||||
List<TimelineEntryDTO> 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<TimelineEntryDTO> buildBirthEvents(List<Person> 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<TimelineEntryDTO> buildDeathEvents(List<Person> 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<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
|
||||
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
|
||||
// in-memory dedup on relationship row id is a defensive assertion.
|
||||
Set<UUID> seen = new HashSet<>();
|
||||
List<TimelineEntryDTO> 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) {
|
||||
|
||||
@@ -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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
List<TimelineEntryDTO> 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<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
List<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> 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<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
110
docs/adr/043-derived-person-events.md
Normal file
110
docs/adr/043-derived-person-events.md
Normal file
@@ -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<UUID>` 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<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user