feat(timeline): add description field to TimelineEntryDTO (REQ-001)

Surfaces the existing TimelineEvent.description through the list DTO so
curated event notes reach the read view. Null for letters and derived
entries; populated from ev.getDescription() in mapEvent() only.

Refs #844
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-16 14:50:40 +02:00
parent 49d8ab78b4
commit afee9df8c0
5 changed files with 86 additions and 9 deletions

View File

@@ -35,6 +35,11 @@ import java.util.UUID;
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* type stays optional.
*
* <p><b>Event description ({@code description}):</b> curator-authored context note for a curated
* {@link Kind#EVENT} entry (#844). Populated from {@link TimelineEvent#getDescription()} — null
* for {@link Kind#LETTER} and derived entries. Deliberately NOT
* {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript type stays optional.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
@@ -55,6 +60,7 @@ public record TimelineEntryDTO(
UUID rootTagId,
String rootTagName,
String rootTagColor,
UUID linkedEventId
UUID linkedEventId,
String description
) {
}

View File

@@ -267,7 +267,7 @@ public class TimelineEventService {
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
null, null, null, null))
null, null, null, null, null))
.toList();
}
@@ -279,7 +279,7 @@ public class TimelineEventService {
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH,
null, null, null, null))
null, null, null, null, null))
.toList();
}
@@ -304,7 +304,7 @@ public class TimelineEventService {
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE,
null, null, null, null));
null, null, null, null, null));
}
}
return result;

View File

@@ -238,7 +238,8 @@ public class TimelineService {
null,
null,
null,
null
null,
ev.getDescription()
);
}
@@ -262,7 +263,8 @@ public class TimelineService {
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color(),
eventByDocId.get(doc.getId())
eventByDocId.get(doc.getId()),
null
);
}

View File

@@ -74,6 +74,28 @@ class TimelineControllerTest {
.andExpect(jsonPath("$.undated").isArray());
}
// ─── REQ-001: description field serialised ───────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void timelineIncludesEventDescription() throws Exception {
// REQ-001 (controller slice): a curated event entry with description "Kontext" is
// serialised into the timeline response at the correct JSON path.
var entry = new TimelineEntryDTO(Kind.EVENT, org.raddatz.familienarchiv.document.DatePrecision.DAY,
false, "", "",
java.time.LocalDate.of(1914, 8, 1), null, "Kriegsbeginn",
EventType.HISTORICAL, UUID.randomUUID(), null, List.of(), null,
null, null, null, null, "Kontext");
when(timelineService.assemble(any()))
.thenReturn(new TimelineDTO(
List.of(new TimelineYearDTO(1914, List.of(entry))),
List.of()));
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.years[0].entries[0].description", is("Kontext")));
}
// ─── Parameter binding ────────────────────────────────────────────────────
@Test

View File

@@ -69,10 +69,10 @@ class TimelineServiceTest {
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
null, null, null, null);
null, null, null, null, null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
null, null, null, null);
null, null, null, null, null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -511,6 +511,53 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList());
}
// ─── event description (#844, REQ-001) ───────────────────────────────────
@Test
void mapEvent_populates_description_from_event() {
// REQ-001: a curated event with a description surfaces it on the assembled entry.
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
.title("Kriegsbeginn").type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 8, 1)).precision(DatePrecision.DAY)
.description("Kontext")
.build();
when(eventRepository.findAll()).thenReturn(List.of(ev));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
TimelineEntryDTO entry = result.years().get(0).entries().get(0);
assertThat(entry.description()).isEqualTo("Kontext");
}
@Test
void mapEvent_leaves_description_null_when_event_has_none() {
// REQ-001: an event without a description → null on the entry.
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
.title("Ereignis").type(EventType.PERSONAL)
.eventDate(LocalDate.of(1920, 1, 1)).precision(DatePrecision.YEAR)
.build();
when(eventRepository.findAll()).thenReturn(List.of(ev));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineEntryDTO entry = timelineService.assemble(noFilters()).years().get(0).entries().get(0);
assertThat(entry.description()).isNull();
}
@Test
void mapDocument_leaves_description_null_for_letter() {
// REQ-001: LETTER entries carry null description, regardless of any document fields.
Document doc = docWithDate(LocalDate.of(1916, 3, 1), DatePrecision.MONTH);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.description()).isNull();
}
// ─── letter→event link (#850, REQ-009) ───────────────────────────────────
@Test
@@ -623,7 +670,7 @@ class TimelineServiceTest {
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
null, null, null, null);
null, null, null, null, null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {