feat(timeline): compute a letter's linkedEventId in TimelineService

For each LETTER entry, resolve the curated event whose documents set
contains the letter, in one batched pass over the events already loaded
(no per-letter query, no new column). The DTO gains a nullable
linkedEventId; non-letter entries keep null.

Refs #850
This commit is contained in:
Marcel
2026-06-15 20:29:57 +02:00
committed by marcel
parent 109202246e
commit 6a56540cf7
5 changed files with 95 additions and 11 deletions

View File

@@ -28,6 +28,13 @@ import java.util.UUID;
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
* {@code null} when the letter is referenced by no curated event (#850). Computed on read from the
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
* 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).
*/
@@ -47,6 +54,7 @@ public record TimelineEntryDTO(
DerivedEventType derivedType,
UUID rootTagId,
String rootTagName,
String rootTagColor
String rootTagColor,
UUID linkedEventId
) {
}

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))
.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))
.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));
}
}
return result;

View File

@@ -80,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// Fetch curated events once — reused for both the event entries below and the
// batched letter→event link resolution (resolveLetterEventLinks), so the
// membership pass costs no extra query. REQ-009.
List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) {
for (TimelineEvent ev : allEvents) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
@@ -107,8 +112,9 @@ public class TimelineService {
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId));
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
}
return bucket(entries);
@@ -229,11 +235,13 @@ public class TimelineService {
null,
null,
null,
null,
null
);
}
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
Map<UUID, UUID> eventByDocId) {
RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO(
Kind.LETTER,
@@ -251,10 +259,38 @@ public class TimelineService {
null,
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color()
root == null ? null : root.color(),
eventByDocId.get(doc.getId())
);
}
/**
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
* event whose {@code documents} set contains the letter (REQ-009). A single doc→event map is
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
* event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built
* from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
* property of the data; the frontend's filter-then-cluster decides whether the linked event is
* actually on screen (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
*/
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
if (letterDocIds.isEmpty()) return Map.of();
Map<UUID, UUID> eventByDocId = new HashMap<>();
for (TimelineEvent ev : events) {
Set<Document> linkedDocs = ev.getDocuments();
if (linkedDocs == null) continue;
for (Document linked : linkedDocs) {
if (letterDocIds.contains(linked.getId())) {
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
}
}
}
return eventByDocId;
}
/**
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),

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);
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);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -511,6 +511,44 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList());
}
// ─── letter→event link (#850, REQ-009) ───────────────────────────────────
@Test
void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-009: linkedEventId = the curated event whose documents set contains the letter.
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
UUID eventId = UUID.randomUUID();
TimelineEvent event = TimelineEvent.builder().id(eventId)
.title("Briefe von der Front").type(EventType.PERSONAL)
.documents(new HashSet<>(Set.of(letterDoc)))
.build(); // no eventDate → event lands undated, leaving the year band to the letter
when(eventRepository.findAll()).thenReturn(List.of(event));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.linkedEventId()).isEqualTo(eventId);
}
@Test
void letter_in_no_curated_event_has_null_linkedEventId() {
// REQ-009: a letter referenced by no curated event → linkedEventId null; the frontend
// then renders it as a loose chronological letter (REQ-006).
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
.title("Anderes Ereignis").type(EventType.PERSONAL)
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
.build();
when(eventRepository.findAll()).thenReturn(List.of(event));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.linkedEventId()).isNull();
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
@@ -523,7 +561,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);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {

View File

@@ -2467,6 +2467,8 @@ export interface components {
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
/** Format: uuid */
linkedEventId?: string;
};
TimelineYearDTO: {
/** Format: int32 */