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:
@@ -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 (#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
|
||||
* {@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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-005.
|
||||
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-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
|
||||
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user