refactor(timeline): adapt TimelineEntryDTO to unified #777 shape

Replace the #776 DTO (primary/relatedPersonName + synthetic String id)
with the full #777 spec: kind, senderName, receiverName, eventId,
documentId, linkedPersonIds, title, eventDateEnd. Derived events now use
title=displayName, linkedPersonIds=[UUID...], eventId=null.

DerivedEventsAssemblyTest updated — all 16 tests pass.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 16:03:00 +02:00
parent 6b593a7bc6
commit 184fc9814a
4 changed files with 56 additions and 47 deletions

View File

@@ -0,0 +1,7 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
public enum Kind {
EVENT,
LETTER
}

View File

@@ -4,28 +4,39 @@ import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/** /**
* Unified DTO for timeline entries — covers both curated {@link TimelineEvent} rows * Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
* ({@code derived=false}) and derived life-events assembled from Person/relationship data * life-events ({@link DerivedEventType}), and archive letters (Documents).
* ({@code derived=true}).
* *
* <p>The {@code id} field is typed {@code String}, not {@code UUID}, because derived events * <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
* carry synthetic prefixed ids ({@code birth:{uuid}}, {@code death:{uuid}}, * means no edit link should be rendered by the frontend.
* {@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 * <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
* {@link Kind#LETTER} entries.
*
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043). * {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/ */
public record TimelineEntryDTO( public record TimelineEntryDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String id, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
LocalDate eventDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
DerivedEventType derivedType, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
String primaryPersonName, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
String relatedPersonName LocalDate eventDate,
LocalDate eventDateEnd,
String title,
EventType type,
UUID eventId,
UUID documentId,
List<UUID> linkedPersonIds,
DerivedEventType derivedType
) { ) {
} }

View File

@@ -263,14 +263,10 @@ public class TimelineEventService {
return persons.stream() return persons.stream()
.filter(p -> p.getBirthDate() != null) .filter(p -> p.getBirthDate() != null)
.map(p -> new TimelineEntryDTO( .map(p -> new TimelineEntryDTO(
"birth:" + p.getId(), Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
EventType.PERSONAL, p.getBirthDate(), null,
p.getBirthDate(), p.getDisplayName(), EventType.PERSONAL,
p.getBirthDatePrecision(), null, null, List.of(p.getId()), DerivedEventType.BIRTH))
true,
DerivedEventType.BIRTH,
p.getDisplayName(),
null))
.toList(); .toList();
} }
@@ -278,14 +274,10 @@ public class TimelineEventService {
return persons.stream() return persons.stream()
.filter(p -> p.getDeathDate() != null) .filter(p -> p.getDeathDate() != null)
.map(p -> new TimelineEntryDTO( .map(p -> new TimelineEntryDTO(
"death:" + p.getId(), Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
EventType.PERSONAL, p.getDeathDate(), null,
p.getDeathDate(), p.getDisplayName(), EventType.PERSONAL,
p.getDeathDatePrecision(), null, null, List.of(p.getId()), DerivedEventType.DEATH))
true,
DerivedEventType.DEATH,
p.getDisplayName(),
null))
.toList(); .toList();
} }
@@ -303,15 +295,15 @@ public class TimelineEventService {
DatePrecision precision = r.getFromYear() != null DatePrecision precision = r.getFromYear() != null
? DatePrecision.YEAR ? DatePrecision.YEAR
: DatePrecision.UNKNOWN; : DatePrecision.UNKNOWN;
String title = r.getPerson().getDisplayName()
+ " & " + r.getRelatedPerson().getDisplayName();
result.add(new TimelineEntryDTO( result.add(new TimelineEntryDTO(
"marriage:" + r.getId(), Kind.EVENT, precision, true, "", "",
EventType.PERSONAL, eventDate, null,
eventDate, title, EventType.PERSONAL,
precision, null, null,
true, List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE, DerivedEventType.MARRIAGE));
r.getPerson().getDisplayName(),
r.getRelatedPerson().getDisplayName()));
} }
} }
return result; return result;

View File

@@ -18,7 +18,6 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -108,7 +107,7 @@ class DerivedEventsAssemblyTest {
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH); assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12)); assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY); assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.primaryPersonName()).isEqualTo(anna.getDisplayName()); assertThat(event.title()).isEqualTo(anna.getDisplayName());
} }
// --- REQ-003: null birthDate → no Geburt event --- // --- REQ-003: null birthDate → no Geburt event ---
@@ -162,7 +161,7 @@ class DerivedEventsAssemblyTest {
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH); assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4)); assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY); assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.primaryPersonName()).isEqualTo(hans.getDisplayName()); assertThat(event.title()).isEqualTo(hans.getDisplayName());
} }
// --- REQ-002 + REQ-003 combined --- // --- REQ-002 + REQ-003 combined ---
@@ -285,10 +284,10 @@ class DerivedEventsAssemblyTest {
List<TimelineEntryDTO> result = service.assembleDerivedEvents(); List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
String id = result.get(0).id(); TimelineEntryDTO entry = result.get(0);
assertThat(id).startsWith("birth:"); assertThat(entry.derived()).isTrue();
assertThatThrownBy(() -> UUID.fromString(id)) assertThat(entry.eventId()).isNull();
.isInstanceOf(IllegalArgumentException.class); assertThat(entry.documentId()).isNull();
} }
// --- REQ-010: display names on Heirat --- // --- REQ-010: display names on Heirat ---
@@ -307,8 +306,8 @@ class DerivedEventsAssemblyTest {
assertThat(heiraten).hasSize(1); assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0); TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.primaryPersonName()).isNotNull().isNotBlank(); assertThat(heirat.title()).isNotNull().isNotBlank();
assertThat(heirat.relatedPersonName()).isNotNull().isNotBlank(); assertThat(heirat.linkedPersonIds()).hasSize(2);
} }
// --- REQ-007 note: assumption/documentation test --- // --- REQ-007 note: assumption/documentation test ---