diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
index 0739cbfb..6d5c5900 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
@@ -28,6 +28,13 @@ import java.util.UUID;
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
+ *
Letter→event link ({@code linkedEventId}): 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.
+ *
*
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
) {
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
index f2ee6d7e..03092647 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
@@ -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;
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
index 7a084205..b63d9bb8 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
@@ -80,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set 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 allEvents = eventRepository.findAll();
+
// ── curated events ───────────────────────────────────────────────────
List 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 rootByDocId = resolveLetterRootTags(letters);
+ Map 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 rootByDocId) {
+ private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId,
+ Map 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-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 all 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 resolveLetterEventLinks(List letters, List events) {
+ Set letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
+ if (letterDocIds.isEmpty()) return Map.of();
+
+ Map eventByDocId = new HashMap<>();
+ for (TimelineEvent ev : events) {
+ Set 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),
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
index 06255ecb..7b2597f6 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
@@ -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 (#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) {
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) {