feat(timeline): implement assembleDerivedEvents() with TDD (REQ-001–REQ-016)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user