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) {