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 * entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* type stays optional. * 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 * <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).
*/ */
@@ -55,6 +60,7 @@ public record TimelineEntryDTO(
UUID rootTagId, UUID rootTagId,
String rootTagName, String rootTagName,
String rootTagColor, String rootTagColor,
UUID linkedEventId UUID linkedEventId,
String description
) { ) {
} }

View File

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

View File

@@ -238,7 +238,8 @@ public class TimelineService {
null, null,
null, null,
null, null,
null null,
ev.getDescription()
); );
} }
@@ -262,7 +263,8 @@ public class TimelineService {
root == null ? null : root.id(), root == null ? null : root.id(),
root == null ? null : root.name(), root == null ? null : root.name(),
root == null ? null : root.color(), 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()); .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 ──────────────────────────────────────────────────── // ─── Parameter binding ────────────────────────────────────────────────────
@Test @Test

View File

@@ -69,10 +69,10 @@ class TimelineServiceTest {
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null, 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, "", "", var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null, 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() var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER) .sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -511,6 +511,53 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList()); 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) ─────────────────────────────────── // ─── letter→event link (#850, REQ-009) ───────────────────────────────────
@Test @Test
@@ -623,7 +670,7 @@ class TimelineServiceTest {
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) { private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "", return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null, 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) { private static Document docWithDate(LocalDate date, DatePrecision precision) {