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
|
* 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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user