feat(timeline): carry each letter's primary root tag in the DTO
TimelineEntryDTO gains three nullable letter-only fields — rootTagId, rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService (ADR-036): id + name + token only, never a serialized Tag entity. A letter's primary tag is the root ancestor of its alphabetically-first assigned tag (#827 Resolved Decision 3); roots are resolved through TagService in one batched pass over the distinct primary tags (no per-letter N+1). The fields are null for non-letter entries, untagged letters, and (color only) a colorless root, so they are deliberately not @Schema(requiredMode = REQUIRED). Refs #835 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,13 @@ import java.util.UUID;
|
|||||||
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
||||||
* an event-type badge for letters.
|
* an event-type badge for letters.
|
||||||
*
|
*
|
||||||
|
* <p><b>Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}):</b> the
|
||||||
|
* letter's primary root tag — the root ancestor of its alphabetically-first assigned tag (#835).
|
||||||
|
* All three are {@code null} for non-{@link Kind#LETTER} entries and for letters with no tags;
|
||||||
|
* {@code rootTagColor} is additionally {@code null} when the resolved root carries no color token.
|
||||||
|
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||||
|
* types stay 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).
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +44,9 @@ public record TimelineEntryDTO(
|
|||||||
UUID eventId,
|
UUID eventId,
|
||||||
UUID documentId,
|
UUID documentId,
|
||||||
List<UUID> linkedPersonIds,
|
List<UUID> linkedPersonIds,
|
||||||
DerivedEventType derivedType
|
DerivedEventType derivedType,
|
||||||
|
UUID rootTagId,
|
||||||
|
String rootTagName,
|
||||||
|
String rootTagColor
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ public class TimelineEventService {
|
|||||||
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
|
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
|
||||||
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))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +278,8 @@ public class TimelineEventService {
|
|||||||
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
|
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
|
||||||
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))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +305,8 @@ public class TimelineEventService {
|
|||||||
title, EventType.PERSONAL,
|
title, EventType.PERSONAL,
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.tag.RootTag;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -52,6 +55,7 @@ public class TimelineService {
|
|||||||
private final TimelineEventService timelineEventService;
|
private final TimelineEventService timelineEventService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
|
private final TagService tagService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assembles the timeline for the given filter. All filters are ANDed.
|
* Assembles the timeline for the given filter. All filters are ANDed.
|
||||||
@@ -95,11 +99,15 @@ public class TimelineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── letters ─────────────────────────────────────────────────────────
|
// ── letters ─────────────────────────────────────────────────────────
|
||||||
List<Document> docs = fetchDocuments(filter.personId());
|
List<Document> letters = new ArrayList<>();
|
||||||
for (Document doc : docs) {
|
for (Document doc : fetchDocuments(filter.personId())) {
|
||||||
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
||||||
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
||||||
entries.add(mapDocument(doc));
|
letters.add(doc);
|
||||||
|
}
|
||||||
|
Map<UUID, RootTag> rootByPrimaryTagId = resolveLetterRootTags(letters);
|
||||||
|
for (Document doc : letters) {
|
||||||
|
entries.add(mapDocument(doc, rootByPrimaryTagId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return bucket(entries);
|
return bucket(entries);
|
||||||
@@ -217,11 +225,15 @@ public class TimelineService {
|
|||||||
ev.getId(),
|
ev.getId(),
|
||||||
null,
|
null,
|
||||||
personIds,
|
personIds,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimelineEntryDTO mapDocument(Document doc) {
|
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByPrimaryTagId) {
|
||||||
|
RootTag root = resolvePrimaryRoot(doc, rootByPrimaryTagId);
|
||||||
return new TimelineEntryDTO(
|
return new TimelineEntryDTO(
|
||||||
Kind.LETTER,
|
Kind.LETTER,
|
||||||
doc.getMetaDatePrecision(),
|
doc.getMetaDatePrecision(),
|
||||||
@@ -235,10 +247,42 @@ public class TimelineService {
|
|||||||
null,
|
null,
|
||||||
doc.getId(),
|
doc.getId(),
|
||||||
List.of(),
|
List.of(),
|
||||||
null
|
null,
|
||||||
|
root == null ? null : root.id(),
|
||||||
|
root == null ? null : root.name(),
|
||||||
|
root == null ? null : root.color()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the root tags for the letters' primary tags in one batched pass — no per-letter
|
||||||
|
* N+1: each letter contributes only its alphabetically-first assigned tag (#835), and
|
||||||
|
* {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
|
||||||
|
*/
|
||||||
|
private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
|
||||||
|
List<Tag> primaryTags = letters.stream()
|
||||||
|
.map(TimelineService::primaryTag)
|
||||||
|
.filter(t -> t != null)
|
||||||
|
.toList();
|
||||||
|
if (primaryTags.isEmpty()) return Map.of();
|
||||||
|
return tagService.resolveRootTags(primaryTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RootTag resolvePrimaryRoot(Document doc, Map<UUID, RootTag> rootByPrimaryTagId) {
|
||||||
|
Tag primary = primaryTag(doc);
|
||||||
|
return primary == null ? null : rootByPrimaryTagId.get(primary.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
|
||||||
|
private static Tag primaryTag(Document doc) {
|
||||||
|
Set<Tag> tags = doc.getTags();
|
||||||
|
if (tags == null || tags.isEmpty()) return null;
|
||||||
|
return tags.stream()
|
||||||
|
.filter(t -> t.getName() != null)
|
||||||
|
.min(Comparator.comparing(Tag::getName))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveSenderName(Document doc) {
|
private String resolveSenderName(Document doc) {
|
||||||
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
||||||
String text = doc.getSenderText();
|
String text = doc.getSenderText();
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ import org.raddatz.familienarchiv.document.DocumentService;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.tag.RootTag;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -27,6 +33,7 @@ class TimelineServiceTest {
|
|||||||
@Mock TimelineEventService timelineEventService;
|
@Mock TimelineEventService timelineEventService;
|
||||||
@Mock DocumentService documentService;
|
@Mock DocumentService documentService;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
|
@Mock TagService tagService;
|
||||||
|
|
||||||
@InjectMocks TimelineService timelineService;
|
@InjectMocks TimelineService timelineService;
|
||||||
|
|
||||||
@@ -61,9 +68,11 @@ class TimelineServiceTest {
|
|||||||
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||||
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);
|
||||||
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);
|
||||||
|
|
||||||
var sorted = List.of(e2, e1).stream()
|
var sorted = List.of(e2, e1).stream()
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||||
@@ -423,13 +432,98 @@ class TimelineServiceTest {
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── root-tag chip enrichment (#835) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_with_tags_carries_its_primary_root_tag() {
|
||||||
|
// REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
|
||||||
|
// assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
|
||||||
|
UUID kriegId = UUID.randomUUID();
|
||||||
|
Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
|
||||||
|
Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
|
||||||
|
Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of());
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||||
|
when(tagService.resolveRootTags(anyList()))
|
||||||
|
.thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.rootTagId()).isEqualTo(kriegId);
|
||||||
|
assertThat(entry.rootTagName()).isEqualTo("Krieg");
|
||||||
|
assertThat(entry.rootTagColor()).isEqualTo("sienna");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void untagged_letter_has_no_root_tag_fields() {
|
||||||
|
// REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
|
||||||
|
Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of());
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.rootTagId()).isNull();
|
||||||
|
assertThat(entry.rootTagName()).isNull();
|
||||||
|
assertThat(entry.rootTagColor()).isNull();
|
||||||
|
verify(tagService, never()).resolveRootTags(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_primary_root_without_color_yields_null_color() {
|
||||||
|
// REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
|
||||||
|
Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of());
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||||
|
when(tagService.resolveRootTags(anyList()))
|
||||||
|
.thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.rootTagId()).isEqualTo(rootId);
|
||||||
|
assertThat(entry.rootTagName()).isEqualTo("Allgemein");
|
||||||
|
assertThat(entry.rootTagColor()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void root_tags_resolved_in_a_single_batched_pass() {
|
||||||
|
// REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
|
||||||
|
UUID kriegId = UUID.randomUUID();
|
||||||
|
Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
|
||||||
|
Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
|
||||||
|
Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
|
||||||
|
Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
|
||||||
|
Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of());
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
|
||||||
|
when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
|
||||||
|
kriegId, new RootTag(kriegId, "Krieg", "sienna"),
|
||||||
|
weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));
|
||||||
|
|
||||||
|
timelineService.assemble(noFilters());
|
||||||
|
|
||||||
|
verify(tagService, times(1)).resolveRootTags(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
||||||
|
assertThat(result.years()).hasSize(1);
|
||||||
|
return result.years().get(0).entries().get(0);
|
||||||
|
}
|
||||||
|
|
||||||
private static TimelineFilter noFilters() {
|
private static TimelineFilter noFilters() {
|
||||||
return new TimelineFilter(null, null, null, null, null);
|
return new TimelineFilter(null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||||
@@ -437,6 +531,12 @@ class TimelineServiceTest {
|
|||||||
.metaDatePrecision(precision).documentDate(date).build();
|
.metaDatePrecision(precision).documentDate(date).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Document docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
|
||||||
|
return Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||||
|
.metaDatePrecision(precision).documentDate(date)
|
||||||
|
.tags(new HashSet<>(Set.of(tags))).build();
|
||||||
|
}
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
|
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
|
||||||
return Document.builder().id(UUID.randomUUID()).title(title)
|
return Document.builder().id(UUID.randomUUID()).title(title)
|
||||||
.metaDatePrecision(precision).documentDate(date).build();
|
.metaDatePrecision(precision).documentDate(date).build();
|
||||||
|
|||||||
Reference in New Issue
Block a user