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

Add a nullable linkedEventId to TimelineEntryDTO — the curated event whose
documents set contains the letter — resolved in one batched membership pass
over the already-loaded events (no per-letter query, no new column). This is
the single backend field the #827 Ereignis grouping mode consumes.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 10:25:00 +02:00
parent f57e59b53c
commit e613a93213
4 changed files with 93 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 * They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional. * 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 (#827). 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 * <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).
*/ */
@@ -47,6 +54,7 @@ public record TimelineEntryDTO(
DerivedEventType derivedType, DerivedEventType derivedType,
UUID rootTagId, UUID rootTagId,
String rootTagName, String rootTagName,
String rootTagColor String rootTagColor,
UUID linkedEventId
) { ) {
} }

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

View File

@@ -80,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers // Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation()); 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-005.
List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ─────────────────────────────────────────────────── // ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>(); List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) { for (TimelineEvent ev : allEvents) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue; if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
@@ -107,8 +112,9 @@ public class TimelineService {
letters.add(doc); letters.add(doc);
} }
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters); Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
for (Document doc : letters) { for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId)); entries.add(mapDocument(doc, rootByDocId, eventByDocId));
} }
return bucket(entries); return bucket(entries);
@@ -229,11 +235,13 @@ public class TimelineService {
null, null,
null, null,
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()); RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO( return new TimelineEntryDTO(
Kind.LETTER, Kind.LETTER,
@@ -251,10 +259,38 @@ public class TimelineService {
null, null,
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())
); );
} }
/**
* 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-005). 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-group decides whether the linked event is
* actually on screen (#827). 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 * 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), * 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"); 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);
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);
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,44 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList()); verify(tagService, times(1)).resolveRootTags(anyList());
} }
// ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
@Test
void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-005: 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-006: a letter referenced by no curated event → linkedEventId null (frontend falls
// back to the per-year "Weitere Briefe" bucket).
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) { private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1); assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0); return result.years().get(0).entries().get(0);
@@ -523,7 +561,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);
} }
private static Document docWithDate(LocalDate date, DatePrecision precision) { private static Document docWithDate(LocalDate date, DatePrecision precision) {